Design tools export metadata, hidden layers, redundant groups and six-decimal coordinates. SVGO 22,703 (github.com/svg/svgo (https://github.com/svg/svgo 22,703 ), MIT, 4.1.0 of August 2026) parses a file into a tree and runs plugins over it: preset-default strips comments and metadata, rounds numbers, merges paths, bakes transforms into path data and shortens IDs. Version 4 dropped removeViewBox and removeTitle from the preset, the two that most often broke scaling and accessibility.
mkdir -p min
npx -y svgo@4.1.0 --no-color booknest-logo.svg booknest-icons.svg \
-o min/booknest-logo.svg min/booknest-icons.svg
grep -o '<symbol' min/booknest-icons.svg | wc -l
npx -y svgo@4.1.0 --no-color --config sprite.config.mjs booknest-icons.svg \
-o min/booknest-icons.svg
grep -o 'symbol id="[a-z-]*"' min/booknest-icons.svg | wc -lbooknest-icons.svg: Done in 25 ms! 1.264 KiB - 93.2% = 0.086 KiB booknest-logo.svg: Done in 12 ms! 1.123 KiB - 24.3% = 0.851 KiB 0 booknest-icons.svg: Done in 32 ms! 1.264 KiB - 6.4% = 1.183 KiB 9
The logo lost a quarter, its spines' rotate() baked into coordinates, but id="mark" became id="a". The sprite "lost" 93% because every <symbol> was deleted: nothing in the file uses them. This configuration keeps them:
export default { plugins: [{ name: 'preset-default', params: { overrides: {
removeHiddenElems: false, removeUselessDefs: false, // both delete <symbol>s the file never uses
cleanupIds: false, // keep id="i-cart" for <use href="#i-cart">
} } }] };Check optimized files visually and diff their IDs before shipping; gzip or Brotli then compress the text further.
<!doctype html>
<style>
body { margin: 0; padding: 8px; font: 13px system-ui, sans-serif; background: #fafaf7; max-width: 600px; }
.cols { display: flex; gap: 10px; }
.col { flex: 1; background: #fff; border: 1px solid #d8d4c8; border-radius: 8px; padding: 8px; min-width: 0; }
.col svg { width: 100%; height: 110px; display: block; }
pre { font-size: 10px; background: #f3f0e8; padding: 6px; height: 130px; overflow: auto; white-space: pre-wrap; word-break: break-all; margin: 6px 0 0; }
h3 { margin: 0 0 4px; font-size: 14px; }
.bar { height: 14px; background: #3d5a80; border-radius: 3px; margin-top: 4px; }
</style>
<div class="cols">
<div class="col"><h3>Exported by an editor</h3><div id="before"></div><div id="bBytes"></div><div class="bar" id="bBar"></div><pre id="bSrc"></pre></div>
<div class="col"><h3>After optimizing</h3><div id="after"></div><div id="aBytes"></div><div class="bar" id="aBar" style="background:#81b29a"></div><pre id="aSrc"></pre></div>
</div>
<script>
// A BookNest logo as a design tool might export it
const exported = `<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Some Vector Editor 28.1 -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:editor="http://example.com/editor" viewBox="0 0 120.000000 80.000000">
<metadata><editor:doc author="designer" created="2026-08-01"/></metadata>
<title>BookNest logo</title>
<g id="Layer_1">
<g id="Group_7">
<rect id="spine_1" x="20.000000" y="15.333333" width="14.666667" height="50.000000" fill="#3D5A80"/>
<rect id="spine_2" x="38.123456" y="10.987654" width="14.666667" height="54.345679" fill="#E07A5F"/>
<rect id="spine_3" x="56.246912" y="20.111111" width="14.666667" height="45.222222" fill="#81B29A"/>
</g>
<g id="Hidden_guides" display="none"><rect x="0" y="0" width="120" height="80" fill="none" stroke="cyan"/></g>
<path id="nest" d="M 12.500000 66.000000 Q 60.000000 84.500000 107.500000 66.000000" fill="none" stroke="#6B4F3A" stroke-width="4.000000"/>
</g>
</svg>`;
// A few of preset-default's plugins, imitated with regular expressions for illustration
// (the real SVGO parses the file into a tree first).
function optimize(src) {
return src
.replace(/<\?xml[^>]*\?>\s*/g, '') // removeXMLProcInst
.replace(/<!--[\s\S]*?-->\s*/g, '') // removeComments
.replace(/<metadata>[\s\S]*?<\/metadata>\s*/g, '') // removeMetadata
.replace(/\s+xmlns:editor="[^"]*"/g, '') // removeEditorsNSData
.replace(/<g[^>]*display="none"[\s\S]*?<\/g>\s*/g, '') // removeHiddenElems
.replace(/\s+id="[^"]*"/g, '') // cleanupIds (none are referenced)
.replace(/\d+\.\d+/g, n => String(+(+n).toFixed(1))) // cleanupNumericValues
.replace(/#[0-9A-F]{6}/gi, c => c.toLowerCase()) // convertColors
.replace(/<\/?g>/g, '') // collapseGroups (no attributes left)
.replace(/>\s+</g, '><') // minify whitespace
.trim();
}
const minified = optimize(exported);
const show = (id, src) => {
document.getElementById(id).innerHTML = src.replace(/<\?xml[^>]*\?>/, '');
document.getElementById(id[0] + 'Src').textContent = src;
};
show('before', exported);
show('after', minified);
const b = new Blob([exported]).size, a = new Blob([minified]).size;
document.getElementById('bBytes').textContent = `${b} bytes`;
document.getElementById('aBytes').textContent = `${a} bytes (−${Math.round(100 - a / b * 100)}%), viewBox and title kept`;
document.getElementById('bBar').style.width = '100%';
document.getElementById('aBar').style.width = (a / b * 100) + '%';
</script>