Tools /

Technology

Icon Library pt.2: A Scalable Approach to Managing Icons.

On 24 / 03 / 2025 / by Robin Vanvoorden

Blog Hero@2x

In our last post, we introduced a Flexible Icon component that made working with SVG icons easier. While this method worked well, we’ve since moved away from it. The main reason? Scalability.

When projects require a large number of icons, importing each one into a single component becomes inefficient. Even though SVG files are small, loading all icons at once creates unnecessary bloat.

To address this issue, we’ve redesigned our icon system for better performance and maintainability.

The New & Improved Icon System

Much like before, our approach still includes:

  1. Exporting all icons as individual SVG files.

  2. Removing height and width attributes to control size via CSS.

  3. Using currentColor for color control through CSS.

  4. Creating an Icon component that dynamically retrieves SVGs.

For some more info about these steps, you can always check: "A Step-by-Step Guide to Developing a Flexible Icon Component."

The Key Difference: Using an SVG Sprite Sheet

Instead of importing icons one by one, we now generate a single SVG spritesheet containing all icons. Each icon in the sheet has an ID, allowing us to reference it efficiently.

Generating the Icon Sprite Sheet

To automate this, we use a script called build-icons.mts, which:

  1. Collects all SVGs in a specific folder

  2. Compiles them into a single sprite.svg file

  3. Generates a type list of available icons for TypeScript

Here’s the core of our script:

import * as path from "node:path";
import fsExtra from "fs-extra";
import { glob } from "glob";
import { parse } from "node-html-parser";
import { optimize } from "svgo";

const cwd = process.cwd();
const inputDir = path.join(cwd, "tooling", "icons", "src");
const inputDirRelative = path.relative(cwd, inputDir);
const typeDir = path.join(cwd, "src", "components", "Atoms", "Icon", "types");
const outputDir = path.join(cwd, "public", "icons");
await fsExtra.ensureDir(outputDir);
await fsExtra.ensureDir(typeDir);

const files = glob
  .sync("**/*.svg", {
    cwd: inputDir,
  })
  .sort((a, b) => a.localeCompare(b));

const shouldVerboseLog = process.argv.includes("--log=verbose");
const logVerbose = shouldVerboseLog ? console.log : () => {};

if (files.length === 0) {
  console.log(`No SVG files found in ${inputDirRelative}`);
} else {
  await generateIconFiles();
}

async function generateIconFiles() {
  const spriteFilepath = path.join(outputDir, "sprite.svg");
  const typeOutputFilepath = path.join(typeDir, "name.d.ts");
  const currentSprite = await fsExtra.readFile(spriteFilepath, "utf8").catch(() => "");
  const currentTypes = await fsExtra.readFile(typeOutputFilepath, "utf8").catch(() => "");

  const iconNames = files.map((file) => iconName(file));

  const spriteUpToDate = iconNames.every((name) => currentSprite.includes(`id=${name}`));
  const typesUpToDate = iconNames.every((name) => currentTypes.includes(`"${name}"`));

  if (spriteUpToDate && typesUpToDate) {
    logVerbose(`Icons are up to date`);
    return;
  }

  logVerbose(`Generating sprite for ${inputDirRelative}`);

  const spriteChanged = await generateSvgSprite({
    files,
    inputDir,
    outputPath: spriteFilepath,
  });

  for (const file of files) {
    logVerbose("✅", file);
  }
  logVerbose(`Saved to ${path.relative(cwd, spriteFilepath)}`);

  const stringifiedIconNames = iconNames.map((name) => JSON.stringify(name));

  const typeOutputContent = `// This file is generated by npm run build:icons
 
export type IconName =
\t| ${stringifiedIconNames.join("\n\t| ")};
`;
  const typesChanged = await writeIfChanged(typeOutputFilepath, typeOutputContent);

  logVerbose(`Manifest saved to ${path.relative(cwd, typeOutputFilepath)}`);

  const readmeChanged = await writeIfChanged(
    path.join(inputDir, "README.md"),
    `# Icons
 
This directory contains SVG icons that are used by the app.
 
Everything in this directory is made into a sprite using \`npm run build:icons\`. This file will show in /public/icons/sprite.svg
`
  );

  if (spriteChanged || typesChanged || readmeChanged) {
    console.log(`Generated ${files.length} icons`);
  }
}

function iconName(file: string) {
  return file.replace(/\.svg$/, "").replace(/\\/g, "/");
}

/**
 * Creates a single SVG file that contains all the icons
 */
async function generateSvgSprite({
  files,
  inputDir,
  outputPath,
}: {
  files: string[];
  inputDir: string;
  outputPath: string;
}) {
  // Each SVG becomes a symbol and we wrap them all in a single SVG
  const symbols = await Promise.all(
    files.map(async (file) => {
      const input = await fsExtra.readFile(path.join(inputDir, file), "utf8");
      const root = parse(input);

      const svg = root.querySelector("svg");
      if (!svg) throw new Error("No SVG element found");

      svg.tagName = "symbol";
      svg.setAttribute("id", iconName(file));
      svg.removeAttribute("xmlns");
      svg.removeAttribute("xmlns:xlink");
      svg.removeAttribute("version");
      svg.removeAttribute("width");
      svg.removeAttribute("height");

      return svg.toString().trim();
    })
  );

  const output = [
    `<?xml version="1.0" encoding="UTF-8"?>`,
    `<!-- This file is generated by npm run build:icons -->`,
    `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="0" height="0">`,
    `<defs>`, // for semantics: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs
    ...symbols,
    `</defs>`,
    `</svg>`,
    "", // trailing newline
  ].join("\n");

  const optimizedResult = optimize(output, {
    multipass: true,
    plugins: [
      {
        name: "preset-default",
        params: {
          overrides: {
            removeUselessDefs: false,
            cleanupIds: false,
            removeHiddenElems: false,
          },
        },
      },
    ],
  });

  const optimizedOutput = optimizedResult.data;

  return writeIfChanged(outputPath, optimizedOutput);
}

async function writeIfChanged(filepath: string, newContent: string) {
  const currentContent = await fsExtra.readFile(filepath, "utf8").catch(() => "");
  if (currentContent === newContent) return false;
  await fsExtra.writeFile(filepath, newContent, "utf8");
  return true;
}

To run the script, add this command to your package.json:

"icons": "npx tsx [PATH]/build-icons.mts"

Now, whenever new icons are added, simply run this script to update the spritesheet automatically.

The New Icon Component

With our new system in place, the Icon component is now simpler and more efficient:

import classNames from "classnames";
import styles from "./Icon.module.scss";
import { type SVGProps } from "react";
import { type IconName } from "./types/name";

export { IconName };

export default function Icon({ name, className, ...props }: SVGProps<SVGSVGElement> & { name: IconName }) {  
  return (
    <span className={classNames(styles.icon, className)}>
      <svg {...props}>
        <use href={`/icons/sprite.svg#${name}`} />
      </svg>
    </span>
  );
}

Key Improvements:

  1. No need to import each icon separately

  2. Single network request instead of multiple imports

  3. Better TypeScript support with type completion

Example Usage: Accordion Item Component

Using the new Icon component in an AccordionItem:

import Icon from "../Icon/Icon";

const AccordionItem = ({ title, info, onClick, open }) => {  
  return (    
    <div className={`${styles.item} ${open ? styles.open : ""}`}>      
      <button className={styles.titleWrapper} onClick={onClick}>      
        <h6 className={styles.title}>{title}</h6>         
        <Icon name="ArrowDown" className={styles.icon} />       
      </button>      
      <div className={styles.infoWrapper}>        
        <div className={styles.infoWrapperInner}>{info}</div>      
      </div>      
    </div>  
  );
};

Now, instead of importing individual icons or enums, we simply pass the icon name as a prop—cleaner, faster, and more scalable!

Conclusion

By switching to an SVG spritesheet, we’ve made our icon system more efficient, scalable, and developer-friendly. This method eliminates unnecessary imports, reduces load times, and provides better TypeScript support.

If you’re managing a large set of icons in your project, this approach will save you time and improve performance.