Back FoxCode

How web icons went from a font hack to 200,000 SVGs on demand

Icon fonts solved the wrong problem. SVG sprites were ergonomically painful. Copy-paste SVG didn't scale. Here's how the ecosystem finally arrived at a working answer.

·9 min read

For about a decade, the dominant way to add icons to a web page was to pretend they were text characters.

The icon font model: package every icon as a glyph in a .woff file, assign it to a Private Use Area Unicode codepoint, reference it via CSS content: "\f015" in a :before pseudo-element, add a class to your HTML element. That was <i class="fa fa-home"></i>. That was Font Awesome. That was how everyone did it from roughly 2012 to 2019 - and the problems were baked in from the start.

Why icon fonts were a bad idea that everyone used anyway

The appeal was genuine at the time. Fonts are vectors - scale without blurring. CSS color changes the icon color. One font file serves the entire set. Universal browser support. For several years this was considered best practice.

The problems:

Flash of unstyled icons. Icon fonts are web fonts. They have the same loading behavior: if the font hasn't loaded when the browser renders text, you either get invisible boxes or raw Unicode characters from the Private Use Area - random glyphs or blank squares. Font Awesome 5 Free's woff2 file ran 60-80KB, loaded in full before a single icon was visible.

Accessibility failures. Screen readers read icon font characters as text. A home icon via content: "\f015" gets announced as "Private Use Area character" or some other meaningless string - behavior varies by screen reader and OS. The workaround was aria-hidden="true" on every icon element plus separate visually-hidden text for screen readers. Boilerplate on every instance, and in practice it was usually skipped.

Rendering artifacts. Icon fonts are anti-aliased as text, not as graphics. On certain browser/OS/DPI combinations - particularly Chrome on Windows at non-standard display scaling - icon glyphs rendered blurry or offset by half a pixel because text rendering hinting was applied. SVG never has this problem.

Single color only. Everything through currentColor. Multi-color icons, duotone variants, gradients - not possible with the font model. The hacks involving stacked glyphs with separate colors were genuinely unhinged to maintain.

Bundle: all or nothing. You loaded the entire icon set even if your interface used 15 of the 1,500+ icons in Font Awesome. Subsetting tools existed but required a build pipeline most projects never configured.

The industry consensus shifted around 2018-2020 as SVG support became truly universal. But the transition wasn't immediately clean.

The SVG problem

SVG icons are technically superior on every axis: crisp at any size, full color support, animatable, properly accessible, no font loading pipeline. The implementation paths were rough.

Copy-paste SVG - dropping raw <svg> markup directly into component templates - works perfectly and has zero dependencies. It also doesn't scale. A UI with 40 icons might have 3,000 lines of SVG path data scattered across components. Updating a single icon means hunting down every instance. Nobody maintains this well.

SVG sprites using <symbol> and <use> solve the duplication problem: define each icon once in a hidden sprite sheet, reference it anywhere with <svg><use href="#icon-home"/></svg>. One definition, N references. But external sprite files run into CSS scoping issues - you cannot style icons from an external .svg using the parent document's CSS, because the external file is in a separate document context. Everything either needs to be inlined in the HTML (bloating every page) or live with limited styling.

Component-per-icon is the pattern the React ecosystem settled on. Lucide React, Heroicons, and similar libraries ship each icon as an individual component that renders an <svg> inline. Bundlers tree-shake unused icons naturally. This is clean for libraries with a fixed, curated icon set. It scales awkwardly when you want access to dozens of different design vocabularies without installing and maintaining a separate package for each.

None of these answers the underlying question: what if you want any icon from any major open-source library in a consistent way?

Iconify: the unification layer

Iconify takes a different approach. Rather than being another icon library, it's an abstraction layer over essentially all of them. A single standardized JSON format, a unified naming convention (prefix:icon-name), and a runtime that can serve any icon on demand.

The scale is significant: over 200,000 icons across 218+ collections - Material Design Icons, Phosphor, Lucide, Heroicons, Tabler, Bootstrap Icons, IBM Carbon, Fluent UI, Remix Icons, Simple Icons, and hundreds more. All accessed identically: <Icon name="mdi:home" />, <Icon name="ph:house" />, <Icon name="lucide:home" />, <Icon name="tabler:home" />.

The underlying format is straightforward. Each collection is one JSON file: icon names mapped to SVG body strings (the inner content, not the outer <svg> wrapper). The runtime combines the body with the collection's shared viewBox at render time. Every collection has a stable prefix you use as a namespace.

INTERACTIVE - ICONIFY COLLECTIONS
Material Design Icons7,400+ iconsbrowse all
home
magnify
account
cog
heart
code-tags
github
palette
star
bell
email
lock
usage<Icon name="mdi:home" />

What makes Iconify's approach durable is that it's a data format, not a rendering implementation. The same icon data works in @iconify/vue, @iconify/react, a plain Web Component, Node.js build tools, and a VS Code extension that previews icons inline as you type names. One format, one naming convention, everything else builds on top.

Building nuxt-icons before the official solution

When I started using Nuxt 3 in early 2022 - still in RC at the time - there was no ergonomic official way to handle SVG icons. The module ecosystem hadn't caught up with Nuxt 3's architecture, and the options were: manually copy-paste SVGs, use a font library, or cobble something together from the available community modules.

I built nuxt-icons to solve the specific problem I had: drop SVG files into a folder, use a single component, have it work. The module handled HMR, treated icons like CSS font elements (controllable via color and font-size), supported nested directories, and preserved original SVG colors for complex icons with the filled prop.

It picked up around 200 stars from people hitting the same gap. The module still works for the narrow use case of purely custom SVG icon collections with no dependency on Iconify's ecosystem. But if you're starting a new project today, @nuxt/icon is the right answer - and it handles custom collections too.

@nuxt/icon: how it actually works

@nuxt/icon is the official Nuxt icon module, now maintained by the Nuxt core team. It wraps @iconify/vue and builds an SSR-aware serving architecture on top.

The full Iconify collection is available without installing separate icon packages. You use <Icon name="mdi:home" /> and the module handles fetching and caching the icon data. If you want a collection bundled into your server build for offline or performance-critical deployments, installing @iconify-json/[prefix] is optional but removes any runtime API dependency.

Icons are served through three tiers:

Client bundle - specific icon names statically analyzed at build time and inlined into the client JS. Zero network requests at runtime. Enable clientBundle.scan: true to auto-detect which icons your components use, or list them explicitly. Ideal for navigation icons, loading spinners, anything on every page.

Server endpoint - a cached Nitro handler at /_nuxt_icon/:collection with a 1-week SWR cache. On first request for a collection, the server returns icon data from locally installed packages or falls back to the public Iconify API. Subsequent requests hit the cache. Transparent to the client.

SSR prefetch - during each server render, the module calls onServerPrefetch for every <Icon> in the tree, loads the icon data, and serializes it into nuxt.payload. The client picks it up from the payload without making any additional network request. Icons are in the DOM on first paint, no separate API call.

nuxt.config.ts
icon: {
  clientBundle: {
    scan: true,         // auto-detect icons from your components
    sizeLimitKb: 256,   // fail build if bundle exceeds this
  },
  serverBundle: 'local', // bundle installed @iconify-json/* packages
}

CSS mode vs SVG mode

This is the one architectural decision you actually make when using @nuxt/icon.

CSS mode (the default) renders <span class="iconify i--mdi--home"> - no SVG in the DOM. The icon is a mask-image with the SVG data URL-encoded into it, applied to a colored background-color. The CSS for each unique icon name is injected once regardless of how many instances appear on the page - 100 instances of the same home icon generate exactly one CSS rule and 100 empty <span> elements.

This has a limit: the mask technique applies a single color to the entire icon shape. Multi-color icons and gradients do not render correctly in CSS mode - everything collapses to currentColor.

SVG mode renders an actual <svg> element inline. Full color support, CSS filters apply, child elements are addressable. More DOM nodes per instance, but necessary for any icon with multiple colors.

<!-- CSS mode (default) - best for single-color UI icons -->
<Icon name="mdi:home" />
<!-- renders: <span class="iconify i--mdi--home"></span> -->

<!-- SVG mode - for multi-color or complex styling needs -->
<Icon name="mdi:home" mode="svg" />
<!-- renders: <svg viewBox="0 0 24 24"><path .../></svg> -->

Set the default at the module level and override per-instance with the mode prop. For a standard UI, CSS mode for everything is the right default.

Custom collections: your own icons, same API

@nuxt/icon treats local SVG directories as first-class collections. Drop your files in a folder, give the collection a prefix:

nuxt.config.ts
icon: {
  customCollections: [
    { prefix: 'local', dir: './app/assets/icons' }
  ]
}
<Icon name="local:logo" />
<Icon name="local:brand/wordmark" />  <!-- nested folder support -->

No build step, no sprite generation, no separate component. Your custom product icons use the exact same <Icon> component and prefix:name syntax as the 200,000+ icons in the Iconify ecosystem. This matters more than it seems: when you switch icon sets or rename icons, there is one place to change and one consistent API across the entire codebase.

If you're on @nuxt/ui, @nuxt/icon is already included as a dependency. The icon: {} config block in nuxt.config.ts still applies - custom collections, client bundle settings, rendering mode defaults - it all works the same.


The icon problem is genuinely solved in 2026. Iconify's unified data layer plus @nuxt/icon's SSR-aware architecture gives you any icon from any major open-source set, rendered without layout shift, without font flashes, with a one-line API. The only remaining question is which collection's visual style fits your design - which is a design question, not an infrastructure one.

Continue Reading