Medium wave · 540–1600 kHz79 stn
1580kHz

Inline SVG Icons Ship With Every Page

A vast dark steampunk print hall where a brass stamping machine endlessly presses the same small glowing neon glyph onto an infinite conveyor of copper sheets, while above it a single ornate glass reliquary holds one master glyph radiating teal and purple light, fog drifting across reflective iron floors

Open any page of your built site and search the HTML for <path. On a typical content site you’ll find the search icon, the menu icon, the theme toggle’s sun and moon, an RSS icon, an external-link arrow, and four or five social marks in the footer. Then open a second page. Same paths, byte for byte.

That’s the default output of every icon component worth installing. lucide-react, react-icons, Heroicons, Astro’s SVG imports — they all render the icon as an inline <svg> element with the full path data embedded in the markup. It’s the right default for a component library, because it works everywhere with zero configuration.

It’s the wrong default for icons that appear on every page of a static site. Those aren’t content. They’re chrome, and chrome belongs in a file the browser downloads once.


What inlining actually costs

Let’s be honest about the transfer size first, because it’s smaller than the raw numbers suggest. A stroke icon from a set like Lucide is a few hundred bytes of markup. Brand marks are heavier; a detailed logo path can run past a kilobyte on its own. A header and footer with a dozen icons lands somewhere around 8 to 15 KB of raw SVG per page.

Brotli and gzip are very good at repetition within a single document, so the compressed cost is a fraction of that. If transfer bytes were the whole story, this would be a footnote.

They aren’t. The real costs are elsewhere:

  1. It’s never cached across pages. Your HTML is the one asset you can’t cache aggressively, because it changes on every deploy. Every byte of icon markup inside it is re-downloaded on every navigation, forever. A sprite with a hashed filename is downloaded once and served from cache for a year.
  2. Hydrated components ship it twice. If an icon lives inside an interactive island, its path data is in the server-rendered HTML and in the JavaScript chunk that hydrates it. The browser downloads, parses, and holds both.
  3. It inflates the DOM. Every <path>, <circle>, and <line> is a real element. Lighthouse’s DOM size audit counts them, and so does every style recalculation on the page.
  4. IDs collide. SVGs with gradients, masks, or clip paths define internal IDs, and optimizers love to shorten them to a, b, c. Inline two such icons on one page and the second icon’s url(#a) resolves to the first icon’s gradient. It’s a real bug, it only appears when two specific icons share a page, and it’s miserable to track down.

None of this is catastrophic. It’s the kind of overhead that never shows up as a single line item, which is exactly why it accumulates.


Measure it first

Before changing anything, find out what you’re actually shipping. This script walks your build output and reports how much of each page is inline SVG:

// scripts/svg-weight.mjs
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';

const root = 'dist';
const files = (await readdir(root, { recursive: true })).filter((f) => f.endsWith('.html'));

const rows = [];
for (const file of files) {
  const html = await readFile(path.join(root, file), 'utf8');
  const svgs = html.match(/<svg[\s\S]*?<\/svg>/g) ?? [];
  const svgBytes = svgs.reduce((n, s) => n + Buffer.byteLength(s), 0);
  rows.push({ file, svgs: svgs.length, svgBytes, htmlBytes: Buffer.byteLength(html) });
}

rows.sort((a, b) => b.svgBytes - a.svgBytes);
for (const r of rows.slice(0, 10)) {
  const pct = ((r.svgBytes / r.htmlBytes) * 100).toFixed(1);
  console.log(`${r.file}  ${r.svgs} svgs  ${r.svgBytes} B  (${pct}% of HTML)`);
}

const total = rows.reduce((n, r) => n + r.svgBytes, 0);
console.log(`\n${files.length} pages, ${(total / 1024).toFixed(0)} KB of inline SVG total`);

The lazy regex will undercount nested SVGs, which is fine for a rough measurement. What you’re looking for is the pattern: if the same 10 KB shows up at the top of every row, that’s chrome, and it’s a candidate for extraction. If one page has a large illustrative diagram, that’s content, and it can stay.


The sprite pattern

An SVG sprite is a single file containing every icon as a <symbol> with an ID. Pages reference symbols by URL with <use>:

<svg class="icon" width="20" height="20" aria-hidden="true">
  <use href="/icons.4f9a2c.svg#search"></use>
</svg>

The page carries about 100 bytes per icon instead of several hundred. The sprite downloads once, on the first page that needs it, and every later navigation pulls it from cache. With a content-hashed filename you can serve it with Cache-Control: public, max-age=31536000, immutable and never think about it again.

Generating the sprite is a small build step. You don’t need a plugin:

// scripts/build-sprite.mjs
import { createHash } from 'node:crypto';
import { readdir, readFile, writeFile, unlink } from 'node:fs/promises';
import path from 'node:path';

const iconDir = 'src/icons';
const files = (await readdir(iconDir)).filter((f) => f.endsWith('.svg')).sort();

const symbols = await Promise.all(
  files.map(async (file) => {
    const svg = await readFile(path.join(iconDir, file), 'utf8');
    const viewBox = svg.match(/viewBox="([^"]+)"/)?.[1] ?? '0 0 24 24';
    const inner = svg.replace(/^[\s\S]*?<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '');
    return `<symbol id="${path.basename(file, '.svg')}" viewBox="${viewBox}">${inner}</symbol>`;
  }),
);

const sprite = `<svg xmlns="http://www.w3.org/2000/svg">${symbols.join('')}</svg>\n`;
const hash = createHash('sha256').update(sprite).digest('hex').slice(0, 8);
const name = `icons.${hash}.svg`;

// Remove stale sprites so public/ doesn't accumulate old versions
for (const old of await readdir('public')) {
  if (/^icons\.[0-9a-f]{8}\.svg$/.test(old) && old !== name) await unlink(path.join('public', old));
}

await writeFile(path.join('public', name), sprite);
await writeFile('src/generated/sprite.ts', `export const spriteUrl = '/${name}';\n`);
console.log(`wrote public/${name} (${files.length} icons)`);

Run it before your build ("prebuild": "node scripts/build-sprite.mjs") and commit the generated module so dev mode works without an extra step. The script is deterministic: the same icons produce the same hash, so the filename only changes when an icon does.

Then the component:

---
// src/components/Icon.astro
import { spriteUrl } from '../generated/sprite';

interface Props {
  name: string;
  size?: number;
  label?: string;
}

const { name, size = 20, label } = Astro.props;
---

<svg
  class="icon"
  width={size}
  height={size}
  role={label ? 'img' : undefined}
  aria-label={label}
  aria-hidden={label ? undefined : 'true'}
>
  <use href={`${spriteUrl}#${name}`} />
</svg>

Set width and height on the outer element. On a cold first visit the icon slot renders empty for the moment it takes the sprite to arrive, and explicit dimensions mean that moment costs zero layout shift.


The gotchas, in order of how often they bite

Root attributes get stripped. Stroke-based sets like Lucide put fill="none", stroke="currentColor", and stroke-width="2" on the root <svg> element, not on the paths. The sprite script above discards the root element, so those attributes disappear and your icons render as solid black blobs. Move them to CSS. Styles on the <use> element’s ancestors inherit into the referenced symbol:

.icon {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  flex-shrink: 0;
}

If you mix stroke icons and filled brand marks, give the filled ones a modifier class that sets fill: currentColor; stroke: none;.

You can’t style individual paths. The symbol’s contents live in a shadow tree that page selectors can’t reach. You get inherited properties and nothing else. For two-tone icons, use CSS custom properties, which inherit through the boundary: write style="fill: var(--icon-accent, currentColor)" on the secondary path in the source SVG and set --icon-accent on the icon.

The sprite must be same-origin. Browsers don’t load external <use> references across origins, and CORS headers don’t change that. If your assets live on a separate CDN hostname, either serve the sprite from the page’s own origin or inline it.

Use href, not xlink:href. The xlink: namespace is deprecated and every browser you support understands plain href on <use>. Older tutorials still show the namespaced form.

Accessibility doesn’t come from the sprite. A decorative icon gets aria-hidden="true". An icon-only button gets its accessible name from the button, not the SVG:

<button type="button" aria-label="Search">
  <Icon name="search" />
</button>

The label prop on the component is for the rarer case where the icon itself conveys meaning outside a labeled control.


When inline is still right

A sprite is a cache strategy, and cache strategies only pay off for things that repeat. Keep the SVG inline when:

  • It appears on one page. An architecture diagram in a single post is content. Inlining it costs nothing extra and keeps it self-contained.
  • It needs per-path styling or animation. Animating individual strokes, hover effects on specific shapes, or anything that needs selectors to reach inside the SVG requires the elements to be in the page’s DOM.
  • Your whole site has four icons. A sprite is a build step, a generated module, and a component. If the inline cost is 1 KB, that machinery isn’t worth maintaining.
  • It’s your logo, above the fold, on the first visit. The logo is the one icon that shouldn’t render empty while a sprite downloads. Inline it in the header and let the rest of the chrome come from the sprite.

There’s also a middle path for purely decorative icons: CSS mask-image with background-color: currentColor turns any SVG file into a recolorable icon with no markup at all. It works well for list bullets and external-link arrows. It’s one request per icon unless you bundle them into a stylesheet, and the result has no accessible name, so keep it away from anything interactive.


The takeaway

Icon components default to inline because inline always works. That’s a library author’s priority, and a reasonable one. Yours is different: you’re shipping the same few kilobytes of path data inside every HTML file you build, on the one asset type you can’t cache.

Run the measurement script. If the same block of SVG tops every row, move your chrome icons into a hashed sprite, let the browser keep it for a year, and leave inline SVG for the things that are actually part of the page.