When designers ask "can I convert this JPG to SVG?", they usually mean one of two things:
- Embed the JPG inside an SVG wrapper (fast, lossless, but not truly vector)
- Trace the JPG into actual vector paths (slow, lossy, but infinitely scalable)
Both are valid. They serve completely different use cases. In this post I'll show you how to implement both entirely in the browser — no server, no Illustrator, no backend.
Why SVG?
SVG (Scalable Vector Graphics) is resolution-independent. A 100-byte SVG can render crisply on a 4K billboard or a smartwatch — because it's math, not pixels.
JPG is the opposite: a fixed grid of pixels. Zoom in enough and it falls apart.
Use cases where you need SVG from a JPG:
- Logos that need to scale to any size
- Icons for UI frameworks
- Print designs (banners, merchandise)
- Laser cutting / CNC files
- CSS/HTML animations on vector shapes
Method 1: Embed JPG Inside SVG (Simple)
This wraps your JPG in an SVG container. It's not "true" vectorization — the image is still raster underneath — but it gives you an .svg file that works in vector workflows and scales without pixelation at the container level.
async function jpgToSvgEmbed(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target.result; // data:image/jpeg;base64,...
// Get image dimensions
const img = new Image();
img.onload = () => {
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="${img.width}"
height="${img.height}"
viewBox="0 0 ${img.width} ${img.height}">
<image href="${base64}"
width="${img.width}"
height="${img.height}"
x="0"
y="0"/>
</svg>`;
const blob = new Blob([svg], { type: 'image/svg+xml' });
resolve(blob);
};
img.src = base64;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
When to use this: Quick conversion, logos on white backgrounds, situations where you just need an .svg file extension and basic scalability.
Limitation: Not editable in Illustrator/Figma as vector paths. Still a raster image inside an SVG wrapper.
Method 2: True Vectorization via Canvas Tracing
This is where it gets interesting. Real vectorization converts pixel regions into mathematical path descriptions. The classic algorithm for this is potrace — originally a C library, now ported to JavaScript.
The Algorithm (Simplified)
1. Convert image to grayscale
2. Apply threshold → binary (black/white) image
3. Trace boundaries between black/white regions
4. Approximate boundaries as Bezier curves
5. Output as SVG <path> elements
Implementation with Potrace.js
// Load from CDN
// <script src="https://cdn.jsdelivr.net/npm/potrace@2.1.8/potrace.js"></script>
async function jpgToSvgTrace(file, options = {}) {
const {
threshold = 128, // 0-255, cutoff between black/white
turdSize = 2, // ignore features smaller than this (noise reduction)
alphaMax = 1, // corner threshold (0=all corners, 1.33=all curves)
optCurve = true, // optimize curves
optTolerance = 0.2 // curve optimization tolerance
} = options;
// Step 1: Load image onto canvas
const canvas = await loadImageToCanvas(file);
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Step 2: Convert to grayscale + threshold
const binaryData = toBinary(imageData, threshold);
// Step 3: Run potrace
return new Promise((resolve, reject) => {
Potrace.loadImageData(binaryData, canvas.width, canvas.height);
Potrace.process({
turnpolicy: Potrace.TURNPOLICY_MINORITY,
turdsize: turdSize,
alphamax: alphaMax,
opticurve: optCurve,
opttolerance: optTolerance,
});
const svg = Potrace.getSVG(1); // scale factor
const blob = new Blob([svg], { type: 'image/svg+xml' });
resolve(blob);
});
}
function loadImageToCanvas(file) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext('2d').drawImage(img, 0, 0);
resolve(canvas);
};
img.src = URL.createObjectURL(file);
});
}
function toBinary(imageData, threshold) {
const data = imageData.data;
const binary = new Uint8ClampedArray(imageData.width * imageData.height);
for (let i = 0; i < binary.length; i++) {
const r = data[i * 4];
const g = data[i * 4 + 1];
const b = data[i * 4 + 2];
// Luminance formula
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
binary[i] = gray < threshold ? 1 : 0; // 1 = black, 0 = white
}
return binary;
}
The Threshold Parameter — Most Important Setting
The threshold value is what determines the output quality most dramatically. It decides which pixels become "black" (traced as vector) and which become "white" (background).
// Low threshold (50) — only very dark pixels become vector
// Result: sparse, loses detail
const svgLight = await jpgToSvgTrace(file, { threshold: 50 });
// Medium threshold (128) — balanced (usually best for logos)
const svgMedium = await jpgToSvgTrace(file, { threshold: 128 });
// High threshold (200) — most pixels become vector
// Result: dense, filled-in, can look solid black
const svgDark = await jpgToSvgTrace(file, { threshold: 200 });
Pro tip: For logos on white backgrounds, start at threshold 128. For photos, you usually want lower (80-100) to pick up only the dominant subject.
Putting It All Together — With a Live Preview
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>JPG to SVG Converter</title>
</head>
<body>
<h1>JPG to SVG Converter</h1>
<input type="file" id="fileInput" accept=".jpg,.jpeg" />
<div>
<label>Mode:</label>
<select id="mode">
<option value="embed">Embed (fast, any image)</option>
<option value="trace">Trace (true vector, best for logos)</option>
</select>
</div>
<div id="traceOptions" style="display:none">
<label>Threshold: <span id="thresholdVal">128</span></label>
<input type="range" id="threshold" min="1" max="254" value="128">
</div>
<button id="convertBtn" disabled>Convert to SVG</button>
<div id="preview"></div>
<script src="https://cdn.jsdelivr.net/npm/potrace@2.1.8/potrace.js"></script>
<script>
const modeSelect = document.getElementById('mode');
const traceOptions = document.getElementById('traceOptions');
const thresholdInput = document.getElementById('threshold');
const thresholdVal = document.getElementById('thresholdVal');
const convertBtn = document.getElementById('convertBtn');
const preview = document.getElementById('preview');
let currentFile = null;
modeSelect.addEventListener('change', () => {
traceOptions.style.display = modeSelect.value === 'trace' ? 'block' : 'none';
});
thresholdInput.addEventListener('input', () => {
thresholdVal.textContent = thresholdInput.value;
});
document.getElementById('fileInput').addEventListener('change', (e) => {
currentFile = e.target.files[0];
convertBtn.disabled = !currentFile;
});
convertBtn.addEventListener('click', async () => {
if (!currentFile) return;
convertBtn.textContent = 'Converting...';
convertBtn.disabled = true;
try {
let blob;
if (modeSelect.value === 'embed') {
blob = await jpgToSvgEmbed(currentFile);
} else {
blob = await jpgToSvgTrace(currentFile, {
threshold: parseInt(thresholdInput.value)
});
}
// Preview
const url = URL.createObjectURL(blob);
preview.innerHTML = `<img src="${url}" style="max-width:400px">`;
// Download
const link = document.createElement('a');
link.href = url;
link.download = currentFile.name.replace(/\.[^.]+$/, '.svg');
link.click();
} catch (err) {
console.error(err);
preview.textContent = 'Error: ' + err.message;
}
convertBtn.textContent = 'Convert to SVG';
convertBtn.disabled = false;
});
// (jpgToSvgEmbed and jpgToSvgTrace functions from above go here)
</script>
</body>
</html>
Performance & Browser Limits
Canvas size limits apply here too. Safari caps canvas at ~16,384px on one axis. For large images, scale down before tracing:
function scaleCanvas(canvas, maxSize = 2000) {
if (canvas.width <= maxSize && canvas.height <= maxSize) return canvas;
const scale = maxSize / Math.max(canvas.width, canvas.height);
const scaled = document.createElement('canvas');
scaled.width = Math.round(canvas.width * scale);
scaled.height = Math.round(canvas.height * scale);
scaled.getContext('2d').drawImage(canvas, 0, 0, scaled.width, scaled.height);
return scaled;
}
Processing time scales with image complexity and resolution. A 500x500 logo traces in ~200ms. A 2000x2000 photo can take 3-5 seconds. Consider using a Web Worker for large files to avoid blocking the UI:
// worker.js
self.onmessage = async (e) => {
const { binaryData, width, height, options } = e.data;
// Run potrace here
self.postMessage({ svg: result });
};
When Client-Side Vectorization Falls Short
Browser-based tracing works well for:
- Logos and icons on clean backgrounds
- Line art and illustrations
- QR codes and barcodes
- Simple shapes
It struggles with:
- Complex photographs (too many color regions)
- Very detailed illustrations
- Images with gradients
For these, server-side tools like Inkscape's autotrace or Adobe's vectorization engine produce much better results — but at the cost of privacy and server infrastructure.
Real-World Tool
If you want to try this without building it yourself, I built a free browser-based JPG to SVG converter at OneWeeb. Both embed and trace modes, threshold slider, live preview, and your files never leave your device.
Wrapping Up
Client-side vectorization is genuinely useful for a large class of images — especially logos, icons, and simple graphics. The key is understanding that you're doing threshold-based tracing, not magic AI upscaling.
The threshold parameter is everything. Spend 30 seconds tuning it and you'll get dramatically better results than the default.
For complex photos, accept the limitations and either use a server-side tool or embrace the "artistic" look that heavy vectorization produces.
Building anything interesting with SVG or the Canvas API? Drop it in the comments — always curious what people are making.

Top comments (0)