SVG Icon Sprites vs Inline Icons
Icons are a small part of a page's bytes and a surprisingly large part of how finished it feels while loading. An icon font leaves blank boxes in the header until a 100 KB font file arrives; a folder of individual SVG files costs a request per icon; a JavaScript icon component library can ship a runtime to render what is fundamentally a few path elements. For static sites, where the goal is a complete first paint from HTML and CSS alone, there are two good options: SVG inline in the HTML, and an external SVG sprite referenced with <use>.
This guide replaces a Font Awesome icon font on an Eleventy documentation site with inline SVG, builds a sprite for a larger icon set on an Astro site, and measures bytes, requests and paint timing for each. It is part of CSS and JavaScript Asset Optimization.
Prerequisites
- The icon set as individual SVG files (Lucide, Heroicons, Tabler, Font Awesome's SVG downloads, or your own).
svgofor optimizing the source SVGs.- Lighthouse and the DevTools Performance panel to observe when icons paint.
Option 1: Inline SVG
Each icon's markup is written directly into the HTML where it is used. There is no request, icons paint with the text, and fill="currentColor" makes them follow the text colour in every theme.
In Eleventy, a shortcode reads the optimized SVG once and caches it:
// eleventy.config.js
import { readFileSync } from 'node:fs';
import { optimize } from 'svgo';
const cache = new Map();
eleventyConfig.addShortcode('icon', (name, label = '') => {
if (!cache.has(name)) {
const raw = readFileSync(`src/icons/${name}.svg`, 'utf8');
cache.set(name, optimize(raw, { multipass: true, plugins: ['preset-default', 'removeDimensions'] }).data);
}
const a11y = label ? `role="img" aria-label="${label}"` : 'aria-hidden="true" focusable="false"';
// prepend class and ARIA attributes to the root element of the optimized markup
return cache.get(name).replace(/^<(svg)/, `<$1 class="icon" ${a11y}`);
});
<button type="button" class="copy">{% icon "copy" %}<span>Copy</span></button>
The Eleventy site used 14 distinct icons, of which 9 appeared on every page (header, footer, callouts). After svgo, the average icon was 290 bytes; the 14 icons added 4.1 KB uncompressed and 2.1 KB brotli-compressed to a typical page — less than the icon font's CSS alone.
Option 2: An External Sprite
A sprite is one SVG file containing a <symbol> per icon. Pages reference icons by fragment:
{# the sprite shortcode renders an inline icon element whose use child points at the symbol #}
{% sprite "search" %} {# → use href="/assets/icons.a1b2c3d4.svg#search" #}
The file downloads once and is cached with the other hashed assets, so every later page gets its icons for free. The Astro site's product documentation used 96 different icons across its templates, many only on specific pages, which made inlining all of them everywhere impractical and inlining per page fiddly. A build step generates the sprite from a folder:
// scripts/build-sprite.mjs
import { globby } from 'globby';
import { readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { optimize } from 'svgo';
let symbols = '';
for (const file of (await globby('src/icons/*.svg')).sort()) {
const id = file.split('/').pop().replace('.svg', '');
const { data } = optimize(await readFile(file, 'utf8'), { plugins: ['preset-default', 'removeDimensions'] });
const viewBox = /viewBox="([^"]+)"/.exec(data)[1];
const inner = data.replace(/^<(svg)[^>]*>/, '').replace(/<\/(svg)>$/, '');
symbols += `<symbol id="${id}" viewBox="${viewBox}">${inner}</symbol>`;
}
const root = 'svg'; // the sprite's root element
const sprite = `<${root} xmlns="http://www.w3.org/2000/svg" style="display:none">${symbols}</${root}>`;
const hash = createHash('sha256').update(sprite).digest('hex').slice(0, 8);
await writeFile(`public/assets/icons.${hash}.svg`, sprite);
await writeFile('src/generated/sprite.json', JSON.stringify({ href: `/assets/icons.${hash}.svg` }));
The content hash in the file name lets the sprite be cached for a year with immutable, as described in Cache Busting with Content-Hashed Filenames. The 96-icon sprite was 31 KB uncompressed and 7.4 KB with brotli.
When the Icons Paint
The difference that readers see is timing. With the icon font on the Eleventy site, header icons appeared about 400 ms after the text on a throttled first visit, because the font request started only after CSS was parsed. Inline SVG painted with the first frame. The Astro sprite painted a little after the first frame on a first visit — the sprite request started as soon as the parser reached the first <use>, which was in the header — and instantly on every later page.
Where a sprite's first-visit delay matters for icons above the fold, preload it: <link rel="preload" href="/assets/icons.a1b2c3d4.svg" as="image" type="image/svg+xml">. Be deliberate, since preloads compete with the LCP image; the Astro site preloaded the sprite only on templates whose LCP element was text.
Accessibility Details
SVG icons are only accessible if they are marked up for their role. Decorative icons next to a visible label get aria-hidden="true" and focusable="false" (the latter for legacy Edge). Icon-only buttons put the accessible name on the button: <button aria-label="Search">, with the SVG hidden. Informative standalone icons — a status indicator in a table, say — get role="img" and aria-label. The icon font previously exposed private-use characters that some screen readers announced as "unpronounceable"; axe-core reported 38 violations across the site's templates, all gone after the switch. The same checks run in Accessibility Testing with axe and pa11y in CI.
Also think about forced-colors mode (Windows High Contrast). currentColor fills follow the system colour automatically; icons with hard-coded fills disappear against system backgrounds. Test with DevTools' "Emulate CSS media feature forced-colors: active".
Migrating Off an Icon Font
The migration itself is mostly search and replace, with a few traps. Start by listing the glyphs actually used: search templates and content for the font's class prefix (fa-, bi-, icon-) and count distinct names — the Eleventy site referenced 14 of Font Awesome's roughly 2,000 glyphs. Map each class to an SVG from the same icon family where possible, since mixing families changes stroke weights and optical sizes. Replace the markup through the shortcode so future changes happen in one place.
Then check sizes and alignment. Icon fonts inherit font-size and sit on the text baseline; SVGs need explicit sizing. A single rule keeps them matched to the surrounding text:
.icon { width: 1em; height: 1em; vertical-align: -0.125em; fill: currentColor; flex-shrink: 0; }
Finally remove the font's @font-face and its CSS. Leaving the stylesheet in place "until everything is migrated" is how many sites end up paying for both.
A Hybrid That Works
Most sites end up with a hybrid: the handful of icons in the header and footer inline, because they appear on every page and must paint with the first frame; everything else from a sprite, because it varies by page and benefits from caching. The Astro site inlined 6 chrome icons (1.3 KB compressed per page) and moved the other 90 to the sprite, which then was not needed at all on 70% of pages and so was not requested there. The build step writes a manifest of which icons each template uses, which also made it easy to delete 23 icons nothing referenced.
Measured Impact
| Measure | Icon font (Eleventy, before) | Inline SVG (Eleventy) | Sprite + 6 inline (Astro) |
|---|---|---|---|
| Icon bytes, first visit (brotli) | 108 KB font + 6 KB CSS | 2.1 KB in HTML | 7.4 KB sprite + 1.3 KB inline |
| Icon bytes, later pages | 0 (cached) | 2.1 KB per page | 1.3 KB per page |
| Requests for icons | 2 | 0 | 0–1 |
| Header icons painted after text (lab) | 400 ms | 0 ms | 0 ms (inline chrome) |
| axe-core icon violations | 38 | 0 | 0 |
| LCP, lab (docs page) | 2.0 s | 1.7 s | 1.6 s |
Pitfalls & Rollback
- External sprites across origins.
<use href>does not load sprites from another origin; serve the sprite from the same host. - Duplicate IDs when inlining. Icons with internal
idattributes (gradients, clip paths) collide when repeated on one page; letsvgoprefix IDs or avoid them in icons. - Fixed
widthandheightin the source. Remove them so CSS controls size. - Forgetting
aria-hiddenon decorative icons. Screen readers may read the SVG title or path data. - Rollback: the shortcode or component is the only integration point; pointing it back at the font markup restores the previous output.
Conclusion
Retire icon fonts. Inline the few icons every page uses so they paint with the first frame, and put the rest in a content-hashed sprite referenced with <use>. On these sites that removed a 108 KB font, 38 accessibility violations and a 400 ms icon flash, for a couple of kilobytes of HTML per page.
FAQ
Why are icon fonts a poor choice today?
They download a font file containing far more glyphs than the site uses, icons stay invisible or show fallback characters until the font loads, screen readers can announce the private-use characters, and readers who override fonts for accessibility lose the icons entirely. SVG avoids all four problems.
When is inline SVG better than a sprite?
When a page uses a small, fixed set of icons, typically fewer than about 20, that appear on every page. Inline SVG needs no request and paints with the HTML. A sprite becomes better when pages use many different icons or when repeating the markup on every page makes HTML noticeably larger.
Can a sprite be styled with CSS?
Yes, when referenced with an svg element containing a use element. Paths that use fill="currentColor" inherit the text colour, so icons follow themes and hover states. A sprite used as a CSS background image cannot be restyled this way.
How do I make SVG icons accessible?
Decorative icons next to a text label should have aria-hidden="true". Icon-only buttons need an accessible name on the button, for example with aria-label or visually hidden text, and the SVG itself stays hidden from assistive technology.
Related
- Parent: CSS and JavaScript Asset Optimization — the full asset pipeline.
- Auditing Font Payload per Page — where icon fonts show up in font budgets.
- Cache Busting with Content-Hashed Filenames — caching the sprite for a year.
- Evaluating SSG Accessibility Defaults — icon accessibility in themes.
- Inlining Critical CSS on Static Sites — the same inline-versus-cached trade-off for CSS.
Filed underComparisons