DEV Community

Cover image for How an Online Stamp Maker Works: A Deep Dive into Browser-Based Design Tools
Mark jay
Mark jay

Posted on

How an Online Stamp Maker Works: A Deep Dive into Browser-Based Design Tools

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 element as the reference curve.

<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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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('');
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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)