Tools /
Technology
Icon Library pt.2: A Scalable Approach to Managing Icons.
)
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:
Exporting all icons as individual SVG files.
Removing
height
andwidth
attributes to control size via CSS.Using
currentColor
for color control through CSS.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:
Collects all SVGs in a specific folder
Compiles them into a single
sprite.svg
fileGenerates 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:
No need to import each icon separately
Single network request instead of multiple imports
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.
)
04 / 21 / 2023
A Step-by-Step Guide to Developing a Flexible Icon Component.
)
02 / 05 / 2025
Bridging Methodologies: Our "Sequential Design" Approach
)
01 / 23 / 2025