Icons on the Web: SVG Sprites, Icon Fonts and Performance
Why SVG icons beat icon fonts in 2026: building an SVG sprite, inline vs third-party vs CSS mask, accessible markup and how to measure the real cost.
A client handed us a marketing site to audit last year. Eleven icons on the homepage: a phone, an envelope, three social links, a few chevrons. Those eleven icons cost roughly 300KB over the wire, because the build pulled in Font Awesome Free in full: the CSS plus the solid and brands woff2 files. Chrome’s Coverage tab put the unused portion of that CSS at over 97%.
We replaced them with a hand-built SVG sprite. Final cost: 1.1KB Brotli’d, inlined into the page, zero extra requests. The icons also stopped flashing in three hundred milliseconds after first paint, which was the complaint that actually got us hired.
That ratio is not unusual, and it’s why the icon font versus SVG argument is basically settled in 2026. But “just use SVG icons” is where most articles stop, and that’s exactly where the real decisions start: inline or sprite, third-party or embedded, fill or mask, and what happens when your icons live inside markup you don’t control.
- Icon fonts fail in ways SVG doesn’t: Private Use Area glyphs read as garbage to some screen readers, render as tofu boxes when users block remote fonts, and sit behind a render-blocking stylesheet plus a font fetch with
font-display: block, giving you up to three seconds of invisible icons. - An third-party SVG sprite referenced with
<use href="icons.svg#name">caches beautifully but can only be styled through inherited properties (fill,stroke,color). Descendant selectors do not cross the shadow boundary. - Inline sprites embedded at the top of the body beat third-party files for first render on most marketing pages. Brotli collapses the repeated
<use>markup to almost nothing, so the “it bloats the HTML” objection rarely survives measurement. - CSS
mask-imagewith a data URI is the right tool for icons you can only reach via::before, such as third-party plugin output, andbackground-color: currentColorgives you colour inheritance for free. - Material Symbols as a variable font is the one genuinely good reason left to use an icon font, because animating the FILL and wght axes is something a sprite cannot do.
What actually breaks with icon fonts
The performance argument gets all the attention, and it’s real, but it’s not the worst problem. The worst problems are the ones you only see in a support ticket.
Most icon fonts map glyphs to the Unicode Private Use Area and inject them through ::before { content: "\f095" }. Screen readers are inconsistent about PUA codepoints. Some skip them. Some announce a random CJK character or a vendor-specific name. We’ve had a VoiceOver user report a nav button being read out as a single meaningless syllable, which took an hour to trace back to a chevron.
Then there’s font blocking. Firefox users who disable remote fonts, corporate proxies that strip font MIME types, OpenDyslexic extensions that force a family override: in all three cases your icons become empty rectangles. A tofu box where a close button should be is a broken product, not a cosmetic bug.
The loading sequence is genuinely bad too. The icon CSS blocks rendering, the font file isn’t even requested until the CSSOM resolves, and Font Awesome ships font-display: block, so the browser hides the glyphs for up to three seconds while it waits. You get a page that paints, then visibly fills in its icons. If you’re already working through render-blocking resources on a static site, the icon font is usually sitting near the top of the list.
One more thing nobody mentions: icon fonts are anti-aliased as text. On Windows with ClearType, a 16px glyph can pick up colour fringing that an SVG path never will.

Why SVG icons became the default
An SVG icon is a path, not a glyph. That single difference removes most of the failure modes above. It also buys you things fonts can never offer: multiple colours per icon, per-element CSS transitions, stroke-dasharray animation, and the ability to ship exactly the eleven icons you use instead of the 2,000 someone else drew.
The icon font vs svg comparison does have one honest point in the font column: a glyph inherits text metrics automatically, so it sits on the baseline without you thinking about it. You can get the same result in one line of CSS. Put it in your base stylesheet once and forget it.
.icon {
width: 1em;
height: 1em;
fill: currentColor; / inherits text colour, including on :hover and in dark mode /
vertical-align: -0.125em; / matches the optical baseline of 16px text /
flex: none; / stops flex children squashing the icon /
}
That flex: none has saved us more debugging time than anything else in the rule. Icons inside a flex row with long labels will shrink to a sliver otherwise, and it only shows up on narrow viewports.
Building an SVG sprite that doesn’t fight you
A sprite is one file containing many <symbol> elements, each with an id. You reference one with <use>. The build is two steps: optimise, then concatenate.
# 1. Strip editor metadata, hardcoded fills and clashing ids
npx svgo -f src/icons -o build/icons --config svgo.config.js
2. Concatenate into a single <symbol> sprite
npx svg-sprite --symbol --symbol-dest=dist --symbol-sprite=icons.svg "build/icons/*.svg"
// svgo.config.js
module.exports = {
plugins: [
{ name: 'preset-default', params: { overrides: { removeViewBox: false } } },
{ name: 'prefixIds', params: { prefix: 'ic' } }, // stops gradient/clipPath id collisions between icons
{ name: 'removeAttrs', params: { attrs: '(fill|stroke)' } } // so currentColor can take over
]
};
Keep removeViewBox: false. Without a viewBox the icon won’t scale, and the default SVGO preset will happily delete it. The prefixIds plugin matters the moment two icons both contain a <clipPath id="a">, at which point one of them silently renders as a blank box in Safari.
Markup is then boring, which is the goal:
<button type="button" class="btn-close" aria-label="Close dialogue">
<svg class="icon" aria-hidden="true" focusable="false">
<use href="/assets/icons.a1b2c3.svg#x-lg"></use>
</svg>
</button>
Use href, not xlink:href. The xlink form has been deprecated for years and the only browser that needed it is long dead. Keep focusable="false": it’s harmless everywhere and still prevents a stray tab stop in some embedded webviews.
The styling limit you will hit
With an third-party sprite, the referenced content lives in a shadow tree. Inherited properties cross that boundary, so fill: currentColor on the host <svg> works. A selector like .icon .primary-path { fill: red } does not, and never will. If you need to recolour parts of a two-tone icon independently, pass CSS custom properties through instead, because those inherit:
.icon-duo { --ic-primary: #0d6efd; --ic-secondary: #adb5bd; }
/ inside the symbol: fill="var(--ic-primary, currentColor)" /
Inline, third-party or mask: choosing per case
Three delivery methods, three distinct trade-offs. Pick deliberately.
- Inline sprite, injected as a hidden
<svg>right after the opening<body>tag. Zero extra requests, icons paint with the first frame, full CSS access. Costs you bytes on every page load with no caching. Best for sites where most visits are a single landing page. - External sprite with a content hash and
Cache-Control: public, max-age=31536000, immutable. Cached across the whole session, but the fetch starts only after the parser hits the first<use>, so above-the-fold icons appear a beat late. Best for apps and multi-page sites. Preload it. - CSS mask, for icons you can only reach through a pseudo-element.
<link rel="preload" href="/assets/icons.a1b2c3.svg" as="image" type="image/svg+xml">
The hybrid we ship most often: inline the four or five icons that appear above the fold, third-party-sprite everything else. It adds some build complexity, and it reliably wins on Largest Contentful Paint when a logo or hero button contains an icon.
The mask approach deserves more use than it gets. When a plugin outputs markup you can’t edit and your only hook is ::before, a mask gives you an SVG with colour inheritance:
.woocommerce-message::before {
content: "";
display: inline-block;
width: 1.25rem;
height: 1.25rem;
background-color: currentColor; / the mask is the shape, this is the colour /
mask: var(--ic-check) center / contain no-repeat;
-webkit-mask: var(--ic-check) center / contain no-repeat; / Safari below 15.4 /
}
:root { --ic-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M13.5 4.5 6 12 2.5 8.5'/%3E%3C/svg%3E"); }
Encode the data URI with single quotes and percent-encode only #, <, > and ". Base64 is about 33% larger and gzips worse. Don’t do it.
One real limit: masks are single-colour by definition. A multi-colour brand logo needs an actual <img> or inline SVG.
Accessible icon markup that survives a review
Two categories, and conflating them is the mistake we see most.
Decorative icons sit next to visible text. A chevron beside the word “Next” adds nothing for a screen reader user. Mark them aria-hidden="true" and move on.
Meaningful icons are the entire accessible name of a control: the bare X, the hamburger, the lone search magnifier. Put the name on the interactive element with aria-label, keep aria-hidden="true" on the SVG itself. Labelling the SVG instead of the button is the classic error, and in several screen reader and browser pairings the name simply doesn’t get exposed.
For a standalone informational icon that isn’t a control, a <title> as the first child plus role="img" works, but check it against your actual target readers. Our default is visually hidden text, because it behaves identically everywhere. The same reasoning we apply to labels, errors and focus management in forms: pick the pattern that fails safely, not the cleverest one.
function sw_icon( string $name, string $label = '' ): string {
// Empty label means decorative. A label means the SVG is the accessible name.
$a11y = $label
? sprintf( 'role="img" aria-label="%s"', esc_attr( $label ) )
: 'aria-hidden="true" focusable="false"';
return sprintf(
'<svg class="icon icon-%1$s" %2$s><use href="%3$s#%1$s"></use></svg>',
esc_attr( $name ),
$a11y,
escurl( getthemefileuri( 'assets/icons.svg' ) )
);
}
Measuring it properly
Lighthouse will not flag an icon font. It isn’t render-blocking in the way Lighthouse defines it, and 150KB of woff2 doesn’t trip any threshold. You have to look yourself.
- Open the Coverage panel in DevTools, reload, and read the unused percentage on your icon CSS. Anything over 90% means you’re shipping a library to use a handful of icons.
- In the Network panel, filter by
fontand check when each file starts, not just how long it takes. A font request that begins 900ms in is a sequencing problem, not a size problem. - Run a WebPageTest filmstrip at 100ms intervals on a throttled connection. Count the frames between first contentful paint and icons appearing. That number is what users actually experience.
- Compare total transfer before and after with Brotli enabled on both sides. Comparing a raw inline sprite against a compressed font file is how people convince themselves sprites are heavy.
If you’re already running a bundler, sprite generation is a ten-line plugin step. We cover where that fits in build tooling for static templates. In Canvas we moved the bundled icon sets to on-demand sprites for exactly this reason: a demo page that uses twelve icons should not download two thousand.
When an icon font is still the right call
Two cases, and only two.
The first is Material Symbols as a variable font. The FILL, wght, GRAD and opsz axes are animatable in CSS, so an outlined star can smoothly fill on hover with a single font-variation-settings transition. No sprite does that. Google Fonts also supports an icon_names parameter that subsets the delivered font to the glyphs you list, which kills the main size objection. If your design system genuinely uses those axes, use the font.
The second is a legacy codebase you aren’t being paid to refactor. If a ten-year-old theme has <i class="fa fa-user"> in four hundred templates, swapping it out is a week of work and a regression risk. Subset the font instead with glyphhanger or fonttools, set font-display: swap, preload it, and spend the week on something that moves a metric.
Everything else, use SVG.
Frequently Asked Questions
Does an third-party SVG sprite work cross-origin?
No, not through <use>. References to a sprite on a different origin are blocked, and CORS headers do not help because <use> has no crossorigin opt-in for SVG documents. Serve the sprite from your own origin, or inline it. If you’re on a CDN subdomain, that counts as cross-origin.
Why is my sprite icon invisible even though the id is correct?
Usually one of three things: the source SVG lost its viewBox during optimisation, the symbol carries a hardcoded fill="#000" that overrides your currentColor, or the host <svg> has no width and height so it collapses to 0 by 0. Check the computed size in DevTools first, it’s the fastest of the three to rule out.
How many icons before an third-party sprite beats inlining?
There’s no fixed number, it depends on pages per session. Our rough rule: under about 25 icons on a predominantly single-page-visit marketing site, inline. Above that, or on any multi-page app where the sprite gets reused across navigations, go third-party with a hashed filename and a one-year immutable cache header.
Can I use SVG icons in HTML email?
Not reliably. Outlook on Windows strips SVG entirely and several webmail clients sanitise it out. Email icons should be PNG at 2x with explicit width and height attributes, plus meaningful alt text, because images are often blocked by default.
Do SVG icons hurt Largest Contentful Paint?
Inline ones never do, since they add no request. An third-party sprite can delay an icon that happens to be inside the LCP element, because the fetch only starts after the parser reaches the first <use>. Preload the sprite with as="image", or inline just the icons that appear above the fold.
If you inherit a site with an icon font, don’t start by rewriting the markup. Start by opening the Coverage panel and the Network waterfall, and write down two numbers: unused CSS percentage and the timestamp of the font request. That tells you whether you have a 300KB problem or a 12KB one, and whether a week of refactoring is justified. If the font is pulling 250KB for a dozen glyphs, build the sprite. If it’s a subset Material Symbols variable font already loading in 40ms, leave it alone and go fix something that’s actually slow.


