Symbol sprites, icon systems and reuse
Build a symbol sprite, theme it with currentColor, weigh the external-file caching trade-off, and keep an icon set consistent.
Building a symbol sprite
<!-- sprite.svg: the sprite file. Symbols are not rendered; they are templates. -->
<svg xmlns="http://www.w3.org/2000/svg" style="display: none">
<symbol id="icon-plus" viewBox="0 0 24 24">
<path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round"/>
</symbol>
<symbol id="icon-bell" viewBox="0 0 24 24">
<path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M13.7 21a2 2 0 01-3.4 0" fill="none" stroke="currentColor" stroke-width="2"/>
</symbol>
<symbol id="icon-star" viewBox="0 0 24 24">
<path d="M12 2l3.1 6.3 6.9 1-5 4.9 1.2 6.8L12 17.8 5.8 21l1.2-6.8-5-4.9 6.9-1z"
fill="currentColor"/>
</symbol>
</svg><!-- Inline sprite: paste the whole hidden SVG once per page. The <use>
references resolve within the document, and currentColor works. -->
<svg width="0" height="0" style="position: absolute" aria-hidden="true">
<symbol id="icon-plus" viewBox="0 0 24 24">
<path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" stroke-width="2"/>
</symbol>
</svg>
<!-- Use it anywhere on the page -->
<button class="btn">
<svg class="icon" width="16" height="16" aria-hidden="true" focusable="false">
<use href="#icon-plus"></use>
</svg>
Add item
</button>
<style>
.icon { color: #6d28d9; display: block; }
/* currentColor lets the icon follow the text colour of its context. */
.btn { color: #0f172a; }
.btn:hover .icon { color: #4c1d95; }
</style>| Sprite form | Colour theming | Caching | Fits |
|---|---|---|---|
| Inline hidden SVG | Full: currentColor and CSS | Not cached separately | A small set used on every page |
External sprite.svg with <use href="file.svg#id"> | currentColor only | One cached file | A large set across many pages |
| Data URI in CSS | None: fixed colour | Cached with the CSS | Tiny decorative marks |
| An icon component that inlines one SVG | Full | Per component | React, Vue and Angular apps |
| Icon font | color only | One cached file | Many monochrome icons, no multi-colour needs |
⚠️
An external sprite referenced with
<use href="sprite.svg#id"> is fetched cross-document, and the browser applies the same-origin rules. If the sprite is on a CDN without CORS headers, the reference silently renders nothing. An inline sprite inside the document avoids the problem entirely.Theming and reuse with use
<svg viewBox="0 0 200 120" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- define the shape once -->
<g id="badge">
<circle cx="0" cy="0" r="16" fill="currentColor"/>
<path d="M-6 0l4 4 8-8" fill="none" stroke="white" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round"/>
</g>
</defs>
<!-- reuse it, positioned and coloured differently each time.
x and y on <use> are a translation of the referenced content. -->
<use href="#badge" x="40" y="60" style="color: #6d28d9"/>
<use href="#badge" x="100" y="60" style="color: #10b981"/>
<use href="#badge" x="160" y="60" style="color: #f59e0b" width="32" height="32"/>
</svg>currentColorinside a symbol resolves against the<use>element's computedcolor, which is what makes one sprite work in every theme.- A
<use>element'sxandytranslate the referenced content, butwidthandheightonly take effect when the target is a<symbol>or a whole SVG with a viewBox. - A sprite cannot change a shape's geometry per use — only its presentation. If two icons differ in shape, they need two symbols.
- CSS can style the contents of a
<use>only through inherited properties such ascolor,fillandstrokewhen those are not set inside the symbol. Settingfilldirectly on an element inside the symbol wins and breaks theming.
// Injecting a sprite once, then referencing it by id: the pattern for an app.
async function loadSprite(url = '/icons/sprite.svg') {
if (document.getElementById('icon-sprite')) return;
const response = await fetch(url);
const markup = await response.text();
const holder = document.createElement('div');
holder.id = 'icon-sprite';
holder.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
holder.innerHTML = markup;
document.body.prepend(holder);
}
// Or componentize it. In React:
// export function Icon({ name, size = 16, label }) {
// return (
// <svg width={size} height={size} role={label ? 'img' : undefined}
// aria-label={label} aria-hidden={label ? undefined : true} focusable="false">
// <use href={`#icon-${name}`} />
// </svg>
// );
// }Keeping a set consistent
| Rule | Reason | Enforcement |
|---|---|---|
One viewBox for the whole set | Icons line up without per-icon tweaks | A lint script over the source files |
| A single stroke width | Mixed widths read as mixed styles | The same script |
| No baked-in colours | Theming breaks otherwise | Grep for fill="# and stroke="# |
| Round line caps and joins | Consistent corners at every size | A converter flag |
| Optical sizing, not mathematical | A thin glyph looks smaller than a solid one | Review at 16px, 24px and 32px |
| An accessible name only when needed | Decorative icons should be silent | Pass a label deliberately |
| Optimised with a fixed config | Predictable output | The same SVGO config for every file |
# A lint pass over an icon directory: catches the mistakes that break a set.
for file in src/icons/*.svg; do
name=$(basename "$file")
grep -q 'viewBox="0 0 24 24"' "$file" || echo "$name: unexpected viewBox"
grep -q 'currentColor' "$file" || echo "$name: no currentColor"
grep -Eq 'fill="#[0-9a-fA-F]{3,6}"' "$file" && echo "$name: hard-coded fill"
grep -Eq 'stroke="#[0-9a-fA-F]{3,6}"' "$file" && echo "$name: hard-coded stroke"
grep -q '<style' "$file" && echo "$name: embedded style block"
grep -q 'id="' "$file" && echo "$name: id that can collide when inlined"
done// A build step that turns a folder of icons into a sprite.
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { optimize } from 'svgo';
const dir = 'src/icons';
const files = (await readdir(dir)).filter((f) => f.endsWith('.svg'));
const symbols = [];
for (const file of files) {
const raw = await readFile(`${dir}/${file}`, 'utf8');
const { data } = optimize(raw, {
multipass: true,
plugins: [
{ name: 'preset-default', params: { overrides: { removeViewBox: false, cleanupIds: false } } },
'removeDimensions'
]
});
const id = 'icon-' + file.replace(/\.svg$/, '');
const body = data.replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '');
const viewBox = /viewBox="([^"]+)"/.exec(data)?.[1] ?? '0 0 24 24';
symbols.push(` <symbol id="${id}" viewBox="${viewBox}">${body}</symbol>`);
}
const sprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">\n${symbols.join('\n')}\n</svg>\n`;
await writeFile('dist/icons/sprite.svg', sprite, 'utf8');
console.log(`sprite: ${files.length} symbols`);The last thing worth deciding early: whether your icons are decorative or meaningful. Decorative icons get aria-hidden="true" and no label, and the button around them carries the accessible name. Meaningful standalone icons get role="img" with an aria-label or a <title> child. Mixing the two inconsistently is the most common accessibility defect in an icon system.
FAQ
Why does my use reference render nothing?
The id does not exist in the loaded document, or the sprite is on another origin without CORS headers. Inline the sprite into the page, or self-host it on the same origin. A typo in the fragment identifier produces exactly the same empty result.
Can I change part of an icon with CSS?
Only inherited properties. A shape inside a symbol that sets its own
fill cannot be overridden from the <use> element, because the shadow content does not participate in selector matching. Remove the hard-coded fill and use currentColor or no fill at all.Related
Authoring, tooling and optimising SVG files Text, fonts and text on a path
Last refreshed 2026-09-18.