Skip to content
Back to Journal
Performance

SVG Icons vs Icon Fonts in 2026: Which to Use and Why

SM
SVG Maker AI
14 min read

SVG icons vs icon fonts stopped being a close contest some time ago: for a new interface, SVG icons are the better default. They are drawings rather than text, so a reader's font override or a font blocker cannot turn them into boxes; they carry more than one colour; and they put no stray characters in front of a screen reader. Icon fonts still work, and a subset one is small. The case against them is how they fail — so this post covers the failures, measures what each approach ships, and ends with a migration path.

SVG icons vs icon fonts: the short answer

Use SVG: a sprite for server-rendered pages and static sites, tree-shaken components in a React, Vue or Svelte app. Keep an icon font only where replacing it costs more than its failure modes — a legacy theme you do not own, or a CMS where editors type icon class names.

SVG icons compared with icon fonts
ConcernIcon fontSVG icons
Rendered asText: baseline, line-height, text rasteriserA drawing with its own box
File fails or is blockedA blank or a boxInline icons cannot fail separately; an external sprite leaves a blank
User font overridesCan become a box or a wordNot affected
Screen readersGlyph may be announced unless hiddenNothing to announce; still needs aria-hidden or a name
Forced coloursRecoloured like textFollows the palette with currentColor; fixed fills stay as authored
Colours per iconOne, colour fonts asideAs many as needed
Unused iconsShipped unless you subsetNot shipped with your own sprite or named imports
CachingFont cached across pagesSprite file cached; inline markup re-sent per page

How each technique works

An icon font is an ordinary web font whose glyphs happen to be pictures. Each sits at a code point — usually in Unicode's Private Use Area, U+E000 to U+F8FF, a range deliberately left without meaning — and a CSS class inserts that character with ::before. Ligature fonts work differently: the element contains the word search and the font draws the word as one glyph. Either way the browser is laying out text, and everything that applies to text applies to the icon.

icon-font.css
/* The "before": a typical self-hosted icon font. */
@font-face {
  font-family: "Acme Icons";
  src: url("/fonts/acme-icons.woff2") format("woff2");
  font-display: block;
}

.ai {
  font-family: "Acme Icons";
  font-style: normal;   /* stop <em> slanting the glyph   */
  font-weight: normal;  /* stop <strong> faking a bold one */
  line-height: 1;
  text-transform: none;
  display: inline-block;
}

/* Each icon is a character in Unicode's Private Use Area. */
.ai-search::before { content: "\e900"; }
.ai-trash::before  { content: "\e901"; }

SVG icons come in three shapes: inline markup, a sprite of <symbol> elements referenced by id, and components that render inline SVG from JavaScript. All three are part of the document, so your CSS can colour them. An SVG in an <img> or a CSS background is a separate document that cannot inherit your colours — fine for illustrations, poor for icons that must match their text.

Rendering, blur and the invisible icon

The old line is that icon fonts are blurry and SVGs are sharp. Both are anti-aliased, and both go soft when an edge lands between device pixels. The difference is control. A glyph's position comes from text layout — baseline, line-height, font metrics — and its edges from the text rasteriser and whatever hinting the font carries. An SVG's coordinates are yours: draw on a grid that matches the rendered size and strokes land on whole pixels, the subject of designing responsive icons.

Being text has quieter side effects. Inside <strong> or <em> a glyph can get synthetic bold or slant when the font lacks that face, which is why icon-font stylesheets reset font-weight and font-style. And the icon cannot appear before the font does.

FOIT, FOUT and the font that never arrives

Browsers fetch a font only once text needs it, so the request starts late unless you preload it with <link rel="preload" href="/fonts/acme-icons.woff2" as="font" type="font/woff2" crossorigin>. While it loads, font-display decides what you see. Font Awesome Free's own stylesheet uses block: a short period where the icon is invisible (FOIT), then the fallback font until the real one arrives. A fallback has nothing sensible at a private-use code point, so you get a box or a gap. Ligature fonts fail more visibly: with swap the word itself shows in your body font (FOUT), wider than the icon, and the layout shifts when the font lands.

Now make the font never arrive. Content blockers can refuse remote fonts — uBlock Origin has a per-site switch for it — a strict font-src policy can refuse it, and a third-party font host can be down. The fallback becomes permanent and every icon-only button loses its picture.

Readers also override fonts deliberately, people with low vision or dyslexia among them. A user stylesheet or reading extension that sets font-family on every element takes the icon font out of the stack, and the code point falls through to system fallback. Firefox's own font preference needed two patches for this — page fonts stay available as a fallback, and since Firefox 111 known icon-font names are allowlisted — which helps only fonts on the list. An SVG is not text, so none of this touches it.

Are icon fonts bad for accessibility?

Not inherently, but their default failure is noisy. CSS generated content is exposed to assistive technology, so the character in ::before becomes part of the page text and of a button's accessible name. What a screen reader does with a private-use character is undefined; an open WebKit bug records VoiceOver reading a Font Awesome glyph as an up arrow. Ligature fonts are worse: the word in the DOM is real text, so it is read aloud, underscores and all.

The fix is the same for both techniques: hide the icon, name the control.

labelled-controls.html
<!-- Icon font: hide the glyph, name the control. -->
<button type="button" aria-label="Delete draft">
  <i class="ai ai-trash" aria-hidden="true"></i>
</button>

<!-- Inline SVG: exactly the same pattern. -->
<button type="button" aria-label="Delete draft">
  <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"
       fill="none" stroke="currentColor" stroke-width="2"
       stroke-linecap="round" stroke-linejoin="round">
    <path d="M10 11v6"/>
    <path d="M14 11v6"/>
    <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
    <path d="M3 6h18"/>
    <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
  </svg>
</button>

<!-- A meaningful icon with no control around it. -->
<svg class="icon" role="img" aria-label="Warning">
  <use href="/icons/sprite.svg#i-triangle-alert"/>
</svg>

Font Awesome's accessibility guidance puts the label on the interactive element, not the icon, and SVG needs the same. A bare <svg> in a button contributes no name — silence rather than noise, but still a failure. A meaningful icon standing alone takes role="img" and an aria-label; accessible vector graphics covers the naming rules in full.

Forced colours

In forced-colours mode — Windows contrast themes are the common case, and the media feature is widely supported — the browser swaps your colours for the user's palette. An icon font is text, so it takes the forced text colour. The CSS Color Adjustment spec gives svg elements forced-color-adjust: preserve-parent-color: the SVG's own fills and strokes are left alone, but color inherits the forced value. So an icon drawn with currentColor follows the palette, while fill="#334155" stays slate grey on what may now be a black background. An SVG in an <img> is not adjusted at all. Interface icons should use currentColor.

Colour, currentColor and multicolour

A glyph has one colour, the text colour, which is convenient until you need two. The workarounds are stacked glyphs aligned by hand, or COLRv1 colour fonts, which caniuse shows working in Chromium browsers and Firefox but not Safari.

SVG takes currentColor for the main colour and as many others as the drawing needs. In a sprite, though, your selectors cannot reach the shapes inside a <use> clone; only inherited values cross. So expose the second colour as a custom property, which inherits:

two-tone.html
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true"
     style="position: absolute; width: 0; height: 0; overflow: hidden">
  <symbol id="i-verified" viewBox="0 0 24 24">
    <circle cx="12" cy="12" r="10" fill="currentColor"/>
    <path d="m8 12 3 3 5-6" fill="none" stroke-width="2"
          stroke-linecap="round" stroke-linejoin="round"
          style="stroke: var(--icon-accent, white)"/>
  </symbol>
</svg>

<style>
  .badge { color: #1e3a8a; --icon-accent: #f97316; }
</style>

<span class="badge">
  <svg class="icon" aria-hidden="true"><use href="#i-verified"/></svg>
  Verified
</span>

Set --icon-accent on any ancestor and every instance picks it up; leave it unset and the fallback applies. Combining SVG and CSS goes further into the <use> boundary.

Performance: what each approach ships

The pro-font argument is one file, one request; the pro-SVG argument is not shipping icons you do not use. Both are about bytes, so I measured: twenty common icons from lucide-react 1.33.0, written out three ways in Node and compressed with gzip level 9 and Brotli quality 11, the components bundled with Rolldown 1.2.7, minified, React external. Font rows are file sizes from jsDelivr's listings; WOFF2 is Brotli-compressed internally, so it travels at roughly that size.

Measured transfer sizes for twenty icons and for full icon sets, in bytes
What shipsRaw bytesgzipBrotli
20 icons as 20 separate .svg files6,0774,1613,585
20 icons as one <symbol> sprite3,5271,116992
20 tree-shaken lucide-react components (JS)9,5922,2932,045
Every lucide-react export, via import *941,733158,950122,846
All 1,776 Lucide drawings as one sprite418,63580,50464,198
Lucide icon font, lucide.woff2 (lucide-static 1.33.0)278,724——
Font Awesome Free 7.3.1, fa-solid-900.woff2119,488——

Twenty separate files compress badly — each is about 300 bytes, too small for gzip to find repetition — and cost twenty requests; one sprite is about a quarter of the bytes. Tree-shaking works: twenty named imports cost 2.3 KB of gzipped JavaScript, Lucide's shared code included, against 159 KB when a namespace import defeats it. And for this stroke-drawn set the whole font is roughly three and a half times the whole sprite gzipped. A font glyph is a filled outline with no stroke, so every line has to become the shape around it, which goes some way to explaining the gap.

No full-library row should ship. Subset the font, or sprite only what you use, and you are comparing two small files. Then caching decides: a font or external sprite is cached once across pages, while inline SVG and server-rendered component markup are re-sent in every HTML response. For a dozen icons that is noise; for a dense admin screen it favours an external sprite. Optimising SVG performance covers the wider picture.

SVG sprites with symbol and use

A sprite is an SVG whose children are <symbol> elements, each with a viewBox and an id; <use> clones one into the page. Use href, not xlink:href, which MDN marks deprecated since SVG 2.

Inline sprite

Put the sprite at the top of the <body> and reference symbols by fragment: no extra request, no cross-origin rules, icons render with the HTML. The cost is re-sending it with every page.

inline-sprite.html
<body>
  <!-- Zero size rather than display: none (see the note below). -->
  <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true"
       style="position: absolute; width: 0; height: 0; overflow: hidden">
    <symbol id="i-search" viewBox="0 0 24 24" fill="none"
            stroke="currentColor" stroke-width="2"
            stroke-linecap="round" stroke-linejoin="round">
      <path d="m21 21-4.34-4.34"/>
      <circle cx="11" cy="11" r="8"/>
    </symbol>
    <!-- ...one <symbol> per icon... -->
  </svg>

  <button type="button" aria-label="Search">
    <svg class="icon" aria-hidden="true"><use href="#i-search"/></svg>
  </button>
</body>

External sprite file

Serve the same file as /icons/sprite.svg and reference it by path plus fragment. The browser fetches it once and caches it.

external-sprite.html
<!-- Same origin as the page. Not a CDN hostname. -->
<a href="/settings">
  <svg class="icon" aria-hidden="true">
    <use href="/icons/sprite.svg#i-settings"/>
  </svg>
  Settings
</a>

<style>
  .icon {
    width: 1em;               /* scale with the text beside it */
    height: 1em;
    vertical-align: -0.125em; /* sit on the baseline like a glyph */
    flex-shrink: 0;           /* keep its size inside flex rows  */
  }
</style>

The catch is the cross-origin limit. MDN notes that browsers may refuse a cross-origin URL in <use> and that there is no defined way to opt in — no crossorigin attribute, no CORS header that helps. Loading the sprite from 127.0.0.1 into a page on localhost gave me a blank icon and a console error saying domains, protocols and ports must match; a sprite on a CDN hostname fails the same way. Serve it from the page's own origin — in Next.js, the public folder (more in SVG in Next.js) — or fetch it and inline it. Why an SVG is not showing covers the other causes of blank icons.

Building the sprite

No dependency needed. This script turns a folder of single-icon SVGs into one sprite, names each symbol after its file and carries the root paint attributes across, since stroke icons such as Lucide's set them there.

build-sprite.mjs
// build-sprite.mjs: node build-sprite.mjs src/icons public/icons/sprite.svg
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, join } from 'node:path'

const [srcDir, outFile] = process.argv.slice(2)
// Paint attributes worth carrying from each file's root <svg> to its <symbol>.
const PAINT = /^(fill|stroke|stroke-width|stroke-linecap|stroke-linejoin)$/

const symbols = readdirSync(srcDir)
  .filter((file) => file.endsWith('.svg'))
  .sort()
  .map((file) => {
    const svg = readFileSync(join(srcDir, file), 'utf8')
    const open = svg.match(/<svg\b([^>]*)>/)
    if (!open) throw new Error(`${file}: no <svg> root`)

    const attrs = [...open[1].matchAll(/([\w:-]+)="([^"]*)"/g)]
    const viewBox = attrs.find(([, name]) => name === 'viewBox')?.[2]
    if (!viewBox) throw new Error(`${file}: no viewBox, so it cannot scale`)

    const paint = attrs.filter(([, name]) => PAINT.test(name)).map(([pair]) => ' ' + pair).join('')
    const body = svg.slice(open.index + open[0].length, svg.lastIndexOf('</svg>')).trim()
    return `  <symbol id="i-${basename(file, '.svg')}" viewBox="${viewBox}"${paint}>${body}</symbol>`
  })

writeFileSync(outFile, `<svg xmlns="http://www.w3.org/2000/svg">\n${symbols.join('\n')}\n</svg>\n`)
console.log(`${symbols.length} symbols -> ${outFile}`)

Ids inside icons — gradients, clip paths — must be unique across the set, because they end up in one document. Optimise icons before building, not the finished sprite: a minifier that drops ids nothing in the file references will strip every symbol id, since symbols are referenced only from your HTML. The SVG Maker AI optimiser (Dashboard, then Tools; it runs in the browser) does exactly that at a threshold of 50 or more, and its slider starts at 60.

Migrating from an icon font to SVG

The font and the sprite can coexist while templates move across, so this is a list, not a rewrite.

  1. Inventory. Count the icon classes in your source, most used first.
  2. Get real SVG sources. Font Awesome Free ships svgs and sprites folders beside its webfonts, and Lucide publishes every icon as a file, so you rarely need to pull glyphs out of a font. For a custom font, go back to the files it was built from.
  3. Normalise. Swap fixed colours for currentColor, keep the viewBox, drop width and height.
  4. Build the sprite and serve it from your own origin.
  5. Replace markup through one component or partial, so the sprite path lives in one place.
  6. Move the labels: aria-hidden on icons, a name on every icon-only control.
  7. Remove the font: the @font-face, the icon CSS and the preload. Grep again for stragglers, then check key screens with forced colours emulated in Chromium DevTools' Rendering panel.
find-icon-usage.sh
# Every icon class in your templates, most used first.
# Modifier classes (fa-solid, fa-lg, fa-fw) show up too: skip them.
grep -rhoE 'fa-[a-z0-9-]+' src/ | sort | uniq -c | sort -rn
Icon.jsx
// Icon.jsx: the one place that knows where the sprite lives.
export function Icon({ name, label, className = 'icon' }) {
  return (
    <svg
      className={className}
      aria-hidden={label ? undefined : true}
      role={label ? 'img' : undefined}
      aria-label={label}
    >
      <use href={`/icons/sprite.svg#i-${name}`} />
    </svg>
  )
}

// Before:
//   <button aria-label="Delete draft"><i className="fa-solid fa-trash" aria-hidden="true" /></button>
// After:
//   <button aria-label="Delete draft"><Icon name="trash-2" /></button>
// A meaningful icon with no control around it:
//   <Icon name="triangle-alert" label="Warning" />

Custom glyphs with no source files need redrawing. The AI icon generator makes SVG icons from a prompt in the SVG Maker AI dashboard (sign up for one free credit). Treat the output like any third-party icon: swap fixed colours for currentColor — paste the markup into the code panel of the SVG editor to do it — and do not expect named groups or readable ids. The sprite script names symbols after your file names, so that does not matter here.

When an icon font is still fine

A CMS where editors add icons by class name is easier to leave on a font. A legacy theme you do not control may not be worth changing, provided decorative glyphs carry aria-hidden and icon-only controls have names. A small, subset, self-hosted, preloaded font is defensible. A full library from a third-party host for a dozen icons, with the glyph as a button's only label, is not.

Where to go next

To redraw icons as a consistent SVG set, start with the AI icon generator, then clean each file in the SVG optimiser before it goes into the sprite. For React, SVG to React covers the attribute renames and wiring currentColor through props. For the drawing itself, read designing responsive icons; for labels, accessible vector graphics.

One boundary: the app icon generator and favicon generator (Icon Studio) export app icons and favicons as PNG. They are for home screens and browser tabs, not for building an SVG sprite.

Frequently asked questions

Are SVG icons better than icon fonts?
For most new interfaces, yes. SVG icons are drawings rather than text, so font blockers and readers' font overrides cannot turn them into boxes, they can use more than one colour, and they put no stray characters in front of a screen reader. Icon fonts still work, and a small subset font is a defensible choice, but an inline SVG, a same-origin sprite or tree-shaken icon components avoid the failure modes that come with rendering icons as text.
Are icon fonts bad for accessibility?
Not inherently, but their default failure is noisy. The character an icon font inserts with ::before is exposed to assistive technology, and what a screen reader does with a Private Use Area character is undefined: it may be skipped or read as an unrelated symbol name. Ligature fonts expose a real word that gets read aloud. Put aria-hidden="true" on every icon and give each icon-only button an accessible name; SVG icons need exactly the same treatment.
Are icon fonts still used?
Yes. Many existing sites, themes and CMS setups still load icon fonts, and libraries such as Font Awesome still publish webfonts alongside SVG files and sprites. They remain reasonable where editors add icons by typing class names or where a legacy theme is not worth changing. For new work, SVG sprites or tree-shaken SVG components are the more robust default.
Is an icon font smaller than SVG icons?
Not necessarily. In our measurement, 20 Lucide icons as one SVG sprite were 1,116 bytes gzipped, and 20 tree-shaken lucide-react components were 2,293 bytes of gzipped JavaScript. Lucide's full icon font is 278,724 bytes as WOFF2, about three and a half times a gzipped sprite of every Lucide icon. What matters most is shipping only the icons you use, by subsetting the font or building a sprite of just those icons.
What is an SVG sprite and how do symbol and use work?
An SVG sprite is one SVG file whose children are symbol elements, each with its own id and viewBox. A use element with href="#id" for an inline sprite, or href="/icons/sprite.svg#id" for an external file, clones that symbol into the page. Draw icons with currentColor so they take the text colour, because your CSS selectors cannot reach the shapes inside the cloned content.
Why does my SVG sprite not show when it is loaded from a CDN?
Browsers may refuse a cross-origin URL in the href of a use element, and in practice they do; there is no crossorigin attribute or CORS header that opts in. A sprite on a CDN hostname or another subdomain therefore renders as a blank icon, with a console error about mismatched domains, protocols or ports. Serve the sprite from the same origin as the page, or fetch it and inline it into the document.
Why do icon fonts show boxes or disappear?
An icon font is text. While it loads, font-display: block hides the icon; if the font fails, is blocked by a content blocker or a font-src policy, or is replaced by a user's own font, the browser falls back to a font that has nothing at the icon's private-use code point, so you see a box or a gap. Ligature fonts show the icon's name as a word instead.
How do I migrate from an icon font to SVG?
Inventory the icon classes you use, then get the SVG sources: Font Awesome Free and Lucide both publish SVG files. Swap fixed colours for currentColor, build a sprite from the files, and serve it from your own origin. Replace markup through one Icon component or partial, put aria-hidden on icons and names on icon-only controls, then remove the @font-face rule, icon CSS and preload, and grep for leftovers.
ShareLinkedInXReddit