EmpireUI
Get Pro
← Blog9 min read#icons#icon library#svg

Building an Icon Library: SVG Sprites, React Components, Tree-Shaking

A real-world guide to building a production icon library with SVG sprites, tree-shakeable React components, and zero-runtime overhead. No fluff.

Developer workspace showing SVG icon files open in a code editor

Why Roll Your Own Icon Library?

You've probably already pulled in Heroicons, Lucide, or Phosphor — and they're fine. But the moment your design team delivers a 200-icon Figma file that doesn't map to any existing library, you're building your own. That's not a punishment. It's a chance to do it right.

The main problems with third-party icon packages aren't the icons themselves — it's the bundle size. In 2024 Lucide React shipped around 1,500 icons. Even with tree-shaking, if your bundler config isn't tight, you're pulling in icons you'll never use. One project I saw was shipping 40 KB of icon SVG data for a dashboard that only showed 12 icons.

Honestly, a custom icon library is one of the most underrated investments in a design system. You control naming, sizing, stroke width, viewport — everything that makes icons feel consistent at 16px versus 24px. And you ship exactly what you need.

Setting Up Your SVG Source Files

Start with clean exports from Figma. Every icon should be a single <svg> element — no groups wrapping nothing, no stray <defs> blocks, and definitely no fill="black" hardcoded in the markup. Your icons need to inherit color from currentColor or they'll be useless in dark mode.

# Install SVGO to clean up Figma exports
npm install -D svgo

Run your exports through SVGO with a config that enforces currentColor. Here's a minimal svgo.config.js that handles 90% of cases: ``js // svgo.config.js module.exports = { plugins: [ 'removeDoctype', 'removeComments', 'removeMetadata', { name: 'convertColors', params: { currentColor: true }, }, { name: 'removeAttrs', params: { attrs: ['xmlns:xlink', 'data-name'] }, }, ], }; ``

Worth noting: always lock your viewport to a single size — I use 0 0 24 24 across the board. Mixed viewboxes between 0 0 20 20 and 0 0 24 24 will silently misalign icons in flex rows. Sounds trivial until you're debugging why your nav icons look slightly off in Safari.

After SVGO cleanup, you'll have a folder of 200 clean SVG files. That's your source of truth. Don't version control the raw Figma exports — only commit the SVGO output.

Option 1: SVG Sprite Approach

SVG sprites are the old-school approach but they still win in specific scenarios — server-rendered pages, non-React environments, or situations where you need instant paint without JavaScript. The idea: bundle all your icons into one sprite.svg file and reference individual icons with <use>.

// scripts/build-sprite.js
const fs = require('fs');
const path = require('path');
const { optimize } = require('svgo');

const iconsDir = path.resolve('./src/icons');
const files = fs.readdirSync(iconsDir).filter(f => f.endsWith('.svg'));

const symbols = files.map(file => {
  const id = path.basename(file, '.svg');
  const raw = fs.readFileSync(path.join(iconsDir, file), 'utf8');
  const { data } = optimize(raw);
  // Strip outer <svg> tags, wrap in <symbol>
  const inner = data.replace(/<svg[^>]*>/, '').replace('</svg>', '');
  return `<symbol id="icon-${id}" viewBox="0 0 24 24">${inner}</symbol>`;
});

const sprite = `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
${symbols.join('\n')}
</svg>`;

fs.writeFileSync('./public/sprite.svg', sprite);
console.log(`Built sprite with ${files.length} icons.`);

In your HTML, drop the sprite inline (or load it async) and reference icons like: ``html <svg width="24" height="24"> <use href="/sprite.svg#icon-arrow-right" /> </svg> ``

The downside here is DX. You're managing string IDs manually, you lose TypeScript autocomplete, and you can't easily pass props like size or className. That's why most React teams move to the component approach. That said, if you're building a multi-framework design system or a documentation site, sprites are still a solid choice.

Option 2: React Components with Tree-Shaking

The React component approach is what you actually want for modern apps. Each icon is its own .tsx file that exports a single component. Bundlers like Vite, esbuild, and Rollup can then dead-code-eliminate anything you don't import. Your app only ships the icons it uses.

Here's the key insight that most tutorials skip: tree-shaking only works if you use named exports from individual files, not a barrel file that re-exports everything. A index.ts that does export * from './icons' defeats the entire point because the bundler has to evaluate the whole thing. ``tsx // src/icons/ArrowRight.tsx import { SVGProps } from 'react'; export function ArrowRight({ size = 24, ...props }: SVGProps<SVGSVGElement> & { size?: number }) { return ( <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" {...props} > <path d="M5 12h14M12 5l7 7-7 7" /> </svg> ); } ``

For 200 icons you're not writing these by hand. You write a codegen script instead: ``js // scripts/generate-components.js const fs = require('fs'); const path = require('path'); const { optimize } = require('svgo'); const iconsDir = './src/icons-svg'; const outDir = './src/components/icons'; fs.mkdirSync(outDir, { recursive: true }); const toPascalCase = str => str.replace(/(^|[-_])([a-z])/g, (_, __, c) => c.toUpperCase()); const files = fs.readdirSync(iconsDir).filter(f => f.endsWith('.svg')); files.forEach(file => { const name = toPascalCase(path.basename(file, '.svg')); const raw = fs.readFileSync(path.join(iconsDir, file), 'utf8'); const { data } = optimize(raw); const inner = data .replace(/<svg[^>]*>/, '') .replace('</svg>', '') .trim(); const component = import { SVGProps } from 'react'; export function ${name}Icon({ size = 24, ...props }: SVGProps<SVGSVGElement> & { size?: number }) { return ( <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" {...props} > ${inner} </svg> ); } ; fs.writeFileSync(path.join(outDir, ${name}Icon.tsx), component); }); console.log(Generated ${files.length} icon components.); ``

In practice, this script runs in under two seconds for 300 icons. Add it to your prebuild npm script and you never think about it again. One more thing — run tsc --noEmit after generation to catch any malformed SVG attributes that React will reject.

Making Tree-Shaking Actually Work

This is where teams consistently trip up. You can write perfect individual component files and still bundle every icon if your config is wrong. Tree-shaking requires three things: ES module output ("type": "module" or .mjs), sideEffects: false in package.json, and a bundler that's actually configured to eliminate dead code.

// package.json (your icon library package)
{
  "name": "@yourorg/icons",
  "version": "1.0.0",
  "sideEffects": false,
  "exports": {
    "./*": "./dist/*.js"
  },
  "files": ["dist"]
}

The sideEffects: false flag is non-negotiable. Without it, Webpack and some Rollup configs won't eliminate unused modules even when they're not imported. Vite handles this better by default but it still respects the flag.

Quick aside: if you're publishing to npm, also add a "module" field pointing to your ES build alongside "main" for CJS. Some older tools still look for "module" explicitly. And in 2026 you can probably skip the CJS build entirely if you're targeting Next.js 14+ or Vite projects, but check your consumers first. ``bash # Verify tree-shaking is working with rollup-plugin-visualizer npm install -D rollup-plugin-visualizer # Then check the generated stats.html after build ``

Look, if the visualizer shows your entire icon set in the bundle after importing three icons, it's almost always the barrel file problem. Delete index.ts, fix your import paths to point directly at component files, rebuild. Done.

Handling Dynamic Icons and Lazy Loading

Sometimes you genuinely need dynamic icons — a user-configurable dashboard, a CMS-driven nav, or a settings panel where icon names come from a database. Static imports won't cut it there. Your options are runtime dynamic import or a lookup map.

The lookup map approach works well for up to ~100 icons and keeps bundle impact predictable: ``tsx // src/components/icons/icon-map.ts import { ArrowRightIcon } from './ArrowRightIcon'; import { CheckIcon } from './CheckIcon'; import { CloseIcon } from './CloseIcon'; // ... add only what your app actually needs export const iconMap = { 'arrow-right': ArrowRightIcon, 'check': CheckIcon, 'close': CloseIcon, } as const; export type IconName = keyof typeof iconMap; ``

// src/components/Icon.tsx
import { iconMap, IconName } from './icons/icon-map';
import { SVGProps } from 'react';

interface IconProps extends SVGProps<SVGSVGElement> {
  name: IconName;
  size?: number;
}

export function Icon({ name, size = 24, ...props }: IconProps) {
  const Component = iconMap[name];
  if (!Component) return null;
  return <Component size={size} {...props} />;
}

For genuinely large icon sets (500+), consider lazy loading with React.lazy and Suspense. Each icon chunk is tiny — around 300–500 bytes after gzip — so the network overhead is minimal, but you avoid the upfront parse cost entirely.

If your team is building a full design system with icon controls and visual previews, check out how Empire UI approaches component consistency — it's worth seeing how a mature library handles variant props before you finalize your own icon API.

Publishing, Versioning, and DX Polish

Once your codegen script is solid, automate the publish pipeline. On every merge to main, run the generator, build with your bundler of choice, and publish to your private npm registry (GitHub Packages, npm, Verdaccio — pick one). Tag releases semantically. A broken icon name change is a breaking change; treat it as a major version bump.

TypeScript autocomplete is the DX win that makes your team actually adopt the library. When a developer types <ArrowR, they want to see ArrowRightIcon suggested immediately. That only happens if your generated components have proper type exports and your package.json exports map includes type declarations: ``json "exports": { "./*": { "import": "./dist/*.js", "types": "./dist/*.d.ts" } } ``

Worth noting: add a Storybook story or a simple HTML preview page that renders all icons in a grid. It sounds like overhead but it's invaluable — designers can sanity-check exports, developers can search by name, and you catch SVGO-mangled icons before they hit production. If you want inspiration for how to display component libraries with style, the glassmorphism components section on Empire UI is a good reference for card-based component grids.

One more thing — lint your icon names. Enforce kebab-case in your SVGO script (or a pre-commit hook) so you don't end up with a mix of arrow_right, ArrowRight, and arrow-right in the same library three months in. Consistency here is what separates a real library from a folder of SVG files.

FAQ

Should I use SVG sprites or React components for my icon library?

React components with tree-shaking win for most modern apps — you get TypeScript autocomplete, prop-based sizing, and zero unused icons in your bundle. Go with SVG sprites if you're in a multi-framework environment or need icons to render before JavaScript loads.

Why isn't tree-shaking removing unused icons even though I'm using named imports?

Almost always it's a barrel file issue. If you have an index.ts that re-exports all icons, the bundler evaluates the whole module. Point your imports directly to individual component files and add "sideEffects": false to your icon package's package.json.

How do I make icons inherit color from their parent element?

Run your SVG exports through SVGO with the convertColors: { currentColor: true } plugin. This replaces hardcoded fill and stroke values with currentColor, so icons automatically match the text color of whatever element wraps them.

How many icons is too many to ship as React components?

With proper tree-shaking there's no real ceiling — Lucide ships 1,500+ components this way. The concern isn't the number of components in the library, it's how many end up in your production bundle. Check with a bundle visualizer after building and you'll see exactly what's being included.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Icon System in React: lucide-react, Heroicons and Custom SVGsSVG Icon Accessibility: aria-label, role and title Explained ProperlyEmpty State With Illustrations in React: SVG, Lottie and CSS ArtSVG Animation in React: stroke-dashoffset, SMIL and Framer Motion