SVG sprites with symbol and use
How to bundle icons into an SVG sprite with symbol and use, style them across the shadow boundary, and decide when a sprite is worth it.
5 min read
An SVG sprite is a single file that holds many icons, each wrapped in a <symbol> element with its own ID. Pages draw an icon by pointing a <use> element at that ID. You get one cached download for a whole set of interface icons, short markup at every use site, and icons that still follow your text color.
Sprites are a good fit for a few dozen interface icons. They are a poor fit for a library of thousands of brand logos. This guide covers how they work, how to style them, and how to tell which situation you are in.
Anatomy of a sprite
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="arrow-up" viewBox="0 0 24 24">
<path d="M12 4l7 8h-5v8h-4v-8H5z" fill="currentColor"/>
</symbol>
<symbol id="copy" viewBox="0 0 24 24">
<path d="M8 8h11v11H8zM5 5h11v2H7v9H5z" fill="currentColor"/>
</symbol>
</svg>
Three details make this work:
- A
<symbol>is never drawn by itself. It is a template that only appears where something references it. - Each symbol carries its own
viewBox, so icons drawn on different grids can live in the same file and still scale correctly. - The
idis the icon's name. Keep IDs unique and readable, such asarrow-upornetwork-ethereum.
Referencing a symbol
To draw an icon, create a small <svg> with a <use> inside it:
<svg class="icon" width="20" height="20" aria-hidden="true">
<use href="/icons/sprite.svg#arrow-up"></use>
</svg>
The href is a URL plus a fragment: the file, then # and the symbol ID. The <use> fills its parent <svg>, and the symbol's viewBox maps the drawing into that 20 × 20 box.
You can also paste the sprite into the page itself, usually right after the opening <body> tag, and reference symbols by fragment alone: <use href="#arrow-up">. That saves a request but repeats the sprite in every HTML response, which is the same caching trade-off as any inline SVG.
Two compatibility notes:
hrefvsxlink:href. Current browsers accept plainhref. The olderxlink:hrefform is only needed for very old browsers.- Same origin only. An external sprite must come from the same origin as the page. A
<use>pointing at a sprite on another domain does not render, even with CORS headers. If your icons live on a CDN domain, serve the sprite from your own origin or inline it.
Styling through the shadow boundary
The browser renders a <use> by cloning the symbol's content into a shadow tree attached to the <use> element. Your page's selectors cannot reach into that tree. A rule like .icon path { fill: red } matches nothing, because from the page's point of view there is no path there.
Inheritance still crosses the boundary. Properties set on the <svg> or <use> element are inherited by the cloned shapes, as long as the shapes do not set those properties themselves. That gives you three reliable styling hooks:
currentColor. Shapes with fill="currentColor" take the CSS color of the host element:
.icon { color: #a1a1aa; }
button:hover .icon { color: #fafafa; }
Inherited fill and stroke. If a shape has no fill attribute at all, it inherits fill from the host, so .icon { fill: #fafafa } works too. A hard-coded fill inside the symbol wins over the inherited value, so leave it out on purpose or use currentColor.
Custom properties for a second color. CSS variables inherit into the shadow tree, which lets a two-tone icon expose both colors:
<symbol id="status" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" style="fill: var(--icon-bg, #3f3f46)"/>
<path d="M10 15l-3-3-1.5 1.5L10 18l8-8-1.5-1.5z" style="fill: var(--icon-fg, #fafafa)"/>
</symbol>
.status-ok { --icon-bg: #14532d; --icon-fg: #dcfce7; }
The fallback values after the comma keep the icon visible when no variable is set.
Hiding an inline sprite
An inline sprite should not take up space, but how you hide it matters. Symbols are not rendered anyway, so the outer <svg> only needs to collapse:
<svg width="0" height="0" style="position: absolute" aria-hidden="true" focusable="false">
...symbols...
</svg>
Avoid display: none on the sprite if any symbol uses a gradient, clip path or mask. Those definitions live inside the hidden element, and in several browsers they stop working when their container is not rendered. The zero-size approach keeps them alive. The same mechanism is behind the bugs described in SVG ID collisions.
That points to a broader limitation: gradients, masks and clip paths inside sprites, especially external ones, have had uneven browser support over the years. Sprites work best for single-color icons. Multi-color logos with gradients are safer as individual files shown with <img>.
Building a sprite
Keep each icon as its own source file and generate the sprite in your build. A minimal script for single-color icons:
// build-sprite.mjs
import { readdir, readFile, writeFile } from 'node:fs/promises'
import { basename, join } from 'node:path'
const dir = 'src/icons/ui'
const files = (await readdir(dir)).filter((f) => f.endsWith('.svg')).sort()
const symbols = []
for (const file of files) {
const svg = await readFile(join(dir, file), 'utf8')
const viewBox = svg.match(/viewBox="([^"]+)"/)?.[1]
if (viewBox === undefined) throw new Error(`${file} has no viewBox`)
const inner = svg.replace(/^[\s\S]*?<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '')
symbols.push(`<symbol id="${basename(file, '.svg')}" viewBox="${viewBox}">${inner.trim()}</symbol>`)
}
await writeFile(
'public/icons/sprite.svg',
`<svg xmlns="http://www.w3.org/2000/svg">${symbols.join('')}</svg>\n`,
)
Run it on already optimized files. If any source file contains internal IDs, prefix them per file first; once every icon shares one document, two files that both define id="a" will collide. Put a content hash in the sprite's file name (or a version query string) so browsers fetch the new sprite when an icon changes.
Accessibility
The <svg> that holds the <use> is what assistive technology sees, so put the name there. Decorative icons next to a visible label get aria-hidden="true". An icon that stands alone, like an icon-only button, needs a name on the button itself, for example aria-label="Copy address". A <title> inside a symbol is not a dependable way to name it.
When a sprite is worth it
| Situation | Sprite? |
|---|---|
| 20 to 100 single-color interface icons used across the app | Yes. One request, cached, recolorable |
| Icons that must change color on hover or theme | Yes, with currentColor |
| A handful of icons on one page | No real gain; inline them |
| Multi-color brand logos with gradients | No; use individual files with <img> |
| A library of thousands of logos where a page shows a few dozen | No |
The last row is the important one for crypto products. At a couple of kilobytes per logo, a sprite with 2,000 token and chain logos is several megabytes, and a page showing 30 of them would download all of it. Every logo update would also invalidate the whole file. Individual files, cached one by one, scale better there; caching SVG files with HTTP headers covers how to serve them.
For the color-following behavior without a sprite, currentColor in SVG explains the mechanism in more depth.