There are five common ways to put an icon on a web page: paste the SVG markup into the HTML, point an <img> at an .svg file, import a component from an icon library, use the file as a CSS mask, or reference a symbol in a sprite file with <use>.
They all draw the same shapes. They differ in how many bytes and requests they cost, how much they add to the DOM, and whether your CSS can recolor them. Instead of describing that in general terms, I built the same page five ways and measured it.
The test
Each page shows the same 20 Lucide icons (lucide-react 0.460.0, 24px, stroke 2) in one row: house, search, user, settings, bell, heart, star, trash, pencil, plus, x, check, two chevrons, arrow-left, mail, calendar, download, upload and menu. The icon files, the sprite and the server-rendered React markup were all generated from the same lucide-react output.
I served the pages from a local server with caching turned off and loaded each one cold in headless Chromium. Requests are the requests the server received. Gzip sizes use level 9, with each file compressed on its own, the way a server sends it. Element counts are the elements inside <body>. For color, the row is #111827 and turns #dc2626 on :hover; nothing else changes.
Results
- Inline SVG: HTML 8,138 bytes (1,547 gzipped). 1 request, 64 elements. Turns red on hover.
<img>: HTML 1,804 bytes (406 gzipped) plus 20 SVG files, 7,291 bytes (4,773 gzipped). 21 requests, 21 elements. Stays black on hover.- React components, server-rendered: HTML 8,144 bytes (1,549 gzipped). 1 request, 64 elements. Turns red on hover. The icon code also ships in your JavaScript: 10,413 bytes (2,370 gzipped) for these 20 imports, React itself not counted.
- CSS mask: HTML 2,147 bytes (615 gzipped) plus the same 20 files. 21 requests, 21 elements. Turns red on hover.
- External sprite: HTML 1,876 bytes (420 gzipped) plus one
sprite.svg, 5,960 bytes (1,275 gzipped). 2 requests, 41 elements. Turns red on hover.

#dc2626 on :hover), headless Chromium at device scale 2. From top: inline SVG, <img>, React components, CSS mask, external sprite.Three things stand out. First, <img> is the only method that ignored the hover color. Second, separate files cost more on the wire than inline markup: the 20 icon files add up to 4,773 bytes gzipped because each one is compressed alone, while the inline page, icons and all, compresses to 1,547 bytes.
Third, the per-file methods cost 21 requests on a cold load, against 1 for inline markup and 2 for a sprite. Over HTTP/2 with a warm cache those requests are cheap, but a first visit pays for all of them.
Inline markup costs DOM instead: 64 elements against 21 for <img>, because every icon brings its own <svg> and paths. With 20 icons that hardly matters. On a page that repeats the same icons hundreds of times, it is the main reason to switch to a sprite.
1. Inline SVG
The <svg> element sits directly in your HTML, so CSS reaches every part of it. This is the markup Lucide’s ArrowRight renders to:
<button class="btn">
Next
<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14"></path>
<path d="m12 5 7 7-7 7"></path>
</svg>
</button>What makes it recolorable is stroke="currentColor" (or fill="currentColor" for filled icons). The icon takes the CSS color of its parent, so hover, disabled and dark-mode colors need no change to the SVG:
.btn { color: #1d4ed8; }
.btn:hover { color: #1e3a8a; } /* the icon follows via currentColor */Sizing the icon in em and aligning it with the label is covered in the stroke width and sizing guide. If the same SVG with a gradient or clip path appears twice on a page, its IDs collide; the React component guide explains the fix.
Inline SVG fits a few dozen UI icons that need hover and theme colors, especially in server-rendered or static HTML. HTML is usually not cached the way static files are, so the markup is sent again with every page.
2. The <img> tag
An SVG file works like any other image. The browser downloads it once, caches it and reuses it across pages:
<!-- Decorative: the text next to it already says "Download" -->
<a href="/report.pdf">
<img src="/icons/download.svg" alt="" width="20" height="20" />
Download report
</a>
<!-- Meaningful on its own: describe what it means, not what it looks like -->
<img src="/icons/verified.svg" alt="Verified seller" width="16" height="16" />The trade-off is isolation. An SVG loaded through <img> is a separate document, so the page’s CSS cannot reach inside it. currentColor resolves inside the file, not from your button, which is why that row stayed black in the test. A blue and a white version need two files.
Scripts inside the SVG do not run, and the file cannot load its own external resources such as a web font. Always set width and height so the browser reserves space before the file arrives, and serve SVG as image/svg+xml, or <img> shows nothing.
Use it for logos, multi-color illustrations, icons that never change color, CMS image fields and email templates.
3. An icon component library
In React, Vue, Svelte or Angular, the usual approach is a package that exposes each icon as a component. It renders inline SVG, so the page behaves like the inline one (the test shows the same 64 elements and the same hover result), and you never paste path data by hand:
// npm i lucide-react
import { ArrowRight, Download } from 'lucide-react';
export function NextButton() {
return (
<button className="btn">
Next <ArrowRight size={20} aria-hidden="true" />
</button>
);
}
// Icon-only control: the button needs the name, the icon stays hidden
<button aria-label="Download report">
<Download size={20} aria-hidden="true" />
</button>The cost moves into your JavaScript bundle. Each icon is its own module, so bundlers drop the ones you do not import; the 20 icons in the test came to 2,370 bytes gzipped. Next.js 16.2.2 also lists lucide-react, @tabler/icons-react and three @heroicons/react entry points in its default optimizePackageImports, so named imports from them are rewritten to per-icon imports.
Catalog sizes and each set’s grid are on the library pages: Lucide, Tabler, Heroicons and Phosphor. Pick one set and stick with it. Sets are drawn on different grids and stroke widths, so two of them side by side rarely look like one family.
4. CSS mask
A mask gives you a cached file and recoloring at once: the SVG becomes the mask of an element whose background-color is currentColor. In the test it followed the hover color like inline SVG while costing the same 21 requests as <img>. The snippet, with the -webkit- prefix it still needs, is in the icon color guide.
It shows a single color only, and the masked element is invisible to assistive technology, so put the label on the parent control.
5. External sprite
A sprite file holds every icon as a <symbol>, and each use is a short reference:
<!-- sprite.svg holds <symbol id="house" viewBox="0 0 24 24">…</symbol> and the rest -->
<svg class="icon" aria-hidden="true"><use href="/sprite.svg#house"/></svg>In the test it was the only file-based method that kept recoloring and cost a single extra request. It must be served from the same origin as the page. Building one, theming it and its other traps are covered in the SVG sprite guide.
Icon fonts
I did not include icon fonts in the test. They were the standard before SVG support was universal, but every icon is text: one color, font hinting, and an empty box or a stray letter if the font fails to load. Unless you subset it, the whole font downloads. For new projects, SVG is the better default.
Accessibility applies to all five
A decorative icon next to a visible label gets aria-hidden="true" (inline SVG, components, sprites) or alt="" (<img>). An icon-only button needs an accessible name on the button. The accessible icons guide covers each library’s defaults and how screen readers announce them.
Which one to use
- Building with React, Vue, Svelte or Angular? A component library. The icons render as inline SVG and cost a few kilobytes of JavaScript.
- Server-rendered or static HTML, icons that need hover or theme colors? Inline SVG for a handful of icons, an external sprite when the same icons repeat across many pages.
- Logos, multi-color art, icons that never change color?
<img>withwidth,heightandalt. - A CSS-only project that wants cached files and one-color theming? A CSS mask, if the extra requests are acceptable.
- An existing icon font? It still works. Move to SVG at the next redesign.
If you need a few icons in a specific color and size without installing a package, Icony exports them as SVG or PNG files for <img>, or copies inline SVG or JSX for the other routes. Select several icons to download them together as a ZIP.