Tools /

Technology

Icon Library pt.2: Een schaalbare benadering voor het beheren van iconen

On 24 / 03 / 2025 / by Robin Vanvoorden

Blog Hero@2x

In onze vorige post introduceerden we een Flexibele Icon-component die het werken met SVG-iconen vergemakkelijkte. Hoewel deze methode goed werkte, zijn we sindsdien van aanpak veranderd. De belangrijkste reden? Schaalbaarheid.

Wanneer projecten een groot aantal iconen vereisen, wordt het importeren van elk icoon in één component inefficiënt. Hoewel SVG-bestanden klein zijn, creëert het laden van alle iconen tegelijkertijd onnodige overhead.

Om dit probleem aan te pakken, hebben we ons iconensysteem herontworpen voor betere prestaties en onderhoudbaarheid.

Het nieuwe en verbeterde iconensysteem

Net als voorheen bevat onze aanpak nog steeds:

  1. Het exporteren van alle iconen als individuele SVG-bestanden.

  2. Het verwijderen van height en width-attributen om de grootte via CSS te regelen.

  3. Het gebruiken van currentColor voor kleurbeheer via CSS.

  4. Het creëren van een Icon-component die SVG's dynamisch ophaalt.

Voor meer informatie over deze stappen kun je altijd onze "Stap-voor-stap handleiding voor het ontwikkelen van een flexibele Icon-component" raadplegen.

Het belangrijkste verschil: Het gebruik van een SVG spritesheet

In plaats van iconen één voor één te importeren, genereren we nu één SVG spritesheet die alle iconen bevat. Elk icoon in de sheet heeft een ID, waardoor we het efficiënt kunnen refereren.

Generating the Icon Sprite Sheet

Om dit te automatiseren, gebruiken we een script genaamd build-icons.mts, dat:

  • Alle SVG-bestanden in een specifieke map verzamelt

  • Ze samenvoegt tot één sprite.svg-bestand

  • Een type-lijst van beschikbare iconen genereert voor TypeScript

Hier is de kern van ons 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;
}

Om het script uit te voeren, voeg je de volgende opdracht toe aan je package.json:

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

Nu, telkens wanneer er nieuwe iconen worden toegevoegd, hoef je alleen dit script uit te voeren om de spritesheet automatisch bij te werken.

Het nieuwe Icon component

Met ons nieuwe systeem is de Icon component nu eenvoudiger en efficiënter:

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>
  );
}

Belangrijkste verbeteringen

  • Geen noodzaak om elk icoon afzonderlijk te importeren

  • Eén netwerkverzoek in plaats van meerdere imports

  • Betere TypeScript-ondersteuning met type-aanvulling

Voorbeeld: Een Accordion Item Component

Het gebruik van de nieuwe Icon component in een 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!

Conclusie

Door over te schakelen naar een SVG spritesheet hebben we ons iconensysteem efficiënter, schaalbaarder en gebruiksvriendelijker gemaakt. Deze methode elimineert onnodige imports, verkort de laadtijden en biedt betere ondersteuning voor TypeScript.

Als je een grote set iconen in je project beheert, zal deze aanpak je tijd besparen en de prestaties verbeteren.