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 formColour themingCachingFits
Inline hidden SVGFull: currentColor and CSSNot cached separatelyA small set used on every page
External sprite.svg with <use href="file.svg#id">currentColor onlyOne cached fileA large set across many pages
Data URI in CSSNone: fixed colourCached with the CSSTiny decorative marks
An icon component that inlines one SVGFullPer componentReact, Vue and Angular apps
Icon fontcolor onlyOne cached fileMany 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>
  • currentColor inside a symbol resolves against the <use> element's computed color, which is what makes one sprite work in every theme.
  • A <use> element's x and y translate the referenced content, but width and height only 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 as color, fill and stroke when those are not set inside the symbol. Setting fill directly 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

RuleReasonEnforcement
One viewBox for the whole setIcons line up without per-icon tweaksA lint script over the source files
A single stroke widthMixed widths read as mixed stylesThe same script
No baked-in coloursTheming breaks otherwiseGrep for fill="# and stroke="#
Round line caps and joinsConsistent corners at every sizeA converter flag
Optical sizing, not mathematicalA thin glyph looks smaller than a solid oneReview at 16px, 24px and 32px
An accessible name only when neededDecorative icons should be silentPass a label deliberately
Optimised with a fixed configPredictable outputThe 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.

Authoring, tooling and optimising SVG files Text, fonts and text on a path

Last refreshed 2026-09-18.