Every method below renders the same icon. What differs is whether your CSS can reach inside it, whether the browser can cache it, and how many network requests you end up making. Those three things are the whole decision.
The short answer
Under about 20 icons on a page: inline SVG. More than that, or the same icons repeated across many pages: SVG sprite. A logo or illustration that never changes colour: img tag. Purely decorative and never dynamic: CSS background.
Method 1: inline SVG
Paste the SVG markup straight into your HTML.
<button class="icon-btn" aria-label="Search">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>
</svg>
</button>
Gives you: full CSS control, including currentColor, hover states, dark mode and animation. Zero extra network requests. Every element is addressable and scriptable.
Costs you: markup weight in the HTML, which is not cached separately, and duplication if the same icon appears many times. Templating or components solve the duplication in practice.
Use it when icons need to respond to state or theme, which is most interface icons, and when the count per page is modest.
Method 2: the img tag
<img src="/icons/search.svg" width="20" height="20" alt="Search">
Gives you: simplicity, browser caching across pages, and clean HTML. Lazy loading with loading="lazy" works normally.
Costs you: all styling control. Your CSS cannot reach inside the file, so currentColor is inert and the icon cannot be recoloured, themed or animated from the page. One HTTP request per icon, though HTTP/2 makes that far less costly than it used to be.
Use it when the icon is a fixed graphic: a logo, an illustration, a full-colour brand mark. Not for interface icons that need states.
Method 3: SVG sprite with use
All your icons live in one file as <symbol> elements, and each usage references one by ID.
<!-- icons.svg -->
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="search" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>
</symbol>
</svg>
<!-- in your page -->
<svg width="20" height="20" aria-hidden="true">
<use href="/icons.svg#search"></use>
</svg>
Gives you: one cached request for every icon on the site, small page markup, and currentColor still works so styling is preserved. It is the only method that gets both caching and colour control.
Costs you: a build step to generate the sprite, and a small caveat that referencing a sprite on a different origin is blocked by CORS. Keep the sprite on your own domain.
Use it when you have a sizeable icon set used across many pages. Full walkthrough in SVG sprites.
Method 4: CSS background or mask
.icon-search {
width: 20px; height: 20px;
background: url("/icons/search.svg") center / contain no-repeat;
}
/* Recolourable variant, using mask */
.icon-mask {
width: 20px; height: 20px;
background-color: currentColor;
-webkit-mask: url("/icons/search.svg") center / contain no-repeat;
mask: url("/icons/search.svg") center / contain no-repeat;
}
Gives you: icons entirely in the stylesheet with no markup at all, useful for pseudo-element decorations. The mask variant restores colour control for single-colour icons.
Costs you: the icon is invisible to assistive technology, which is correct for decoration and wrong for anything meaningful. Data-URI encoding bloats the stylesheet if overused.
Use it when the icon is genuinely decorative: a chevron on a list item, a bullet marker, a background flourish.
Side by side
| Inline | img | Sprite | CSS bg | |
|---|---|---|---|---|
| CSS can recolour it | Yes | No | Yes | Mask only |
| Animatable from the page | Yes | No | Partly | No |
| Cached separately | No | Yes | Yes | Yes |
| Requests | 0 | 1 per icon | 1 total | 1 per icon |
| Adds page weight | Yes | No | Minimal | No |
| Screen reader accessible | Yes | Yes, via alt | Yes | No |
| Needs a build step | No | No | Usually | No |
Accessibility, whichever you choose
The rule does not change with the method: an icon either conveys information or it does not.
- Icon-only button. The button needs an accessible name, the icon should be hidden. Put
aria-labelon the button andaria-hidden="true"on the SVG. - Icon beside visible text. The text already carries the meaning. Hide the icon with
aria-hidden="true", or it gets announced twice. - Icon carrying meaning alone, such as a status indicator. Give it a text equivalent with
role="img"and anaria-label, or visually hidden text. - img tag. Meaningful icons get real alt text, decorative ones get
alt="". Never omit the attribute; a missing alt makes screen readers read the filename.
Full patterns in accessible icons.
In React, Vue and Svelte
Component frameworks make inline SVG practical at scale, because the duplication argument disappears: the icon is written once as a component and used everywhere.
export function SearchIcon({ size = 20, ...props }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" aria-hidden="true" {...props}>
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
);
}
Or install the official package for your set, lucide-react, @tabler/icons-react, @phosphor-icons/react, and let tree shaking ship only the icons you import. Note the JSX attribute names: strokeWidth and strokeLinecap rather than the hyphenated HTML forms.
Mistakes that cost time
- Missing viewBox. The icon will not scale. Always keep it.
- Setting width and height only in HTML. Set size in CSS so it is responsive and overridable.
- Using an img tag then wanting hover colours. Decide styling needs before choosing the method.
- Duplicate IDs across inline SVGs. Gradient and clip path IDs collide and icons render wrongly. Prefix them.
- Serving a sprite from a CDN on another origin.
<use>is blocked cross-origin. Keep it local. - Shipping unoptimised design tool exports. Editor metadata can triple the file size. See optimising SVG.