Topics

Tree shaking icons

Import the icons you use and the rest of the set stays out of your bundle. This guide shows what makes that work, what quietly breaks it, and what the numbers look like in a real build.

What you need

Any bundler that tree shakes ES modules: Vite, Rollup, webpack 5 or Rspack in production mode, Parcel, or esbuild with bundling and code splitting turned on. There is nothing to configure. Use named imports from the ESM entry points:

App.jsx
import { HomeIcon, SearchIcon } from 'glyphfox/react';
import { iconHome } from 'glyphfox';            // icon data, also tree shaken
import { renderToString } from 'glyphfox/core'; // the engine without icon data

The same applies to glyphfox/vue, which is built exactly like the React entry, and to the Svelte components, which are one file per icon as well. Development servers do not tree shake, so judge size only on production builds.

How it works

Three properties of the package let a bundler drop every icon you do not import.

  1. One module per icon. The build writes each icon as its own file, and the index of every entry point only re-exports them. A bundler can follow the one re-export you use and ignore the others.
  2. Pure calls. Each component is created with /*#__PURE__*/ createIcon(icon), which tells the minifier that the call has no effect worth keeping when its result is unused.
  3. Declared side effects. The sideEffects field of package.json lists the only files that do something on import. Every other module may be skipped entirely when none of its exports are used.
glyphfox/dist/react/icons/home.js
import { createIcon } from '../runtime.js';
import icon from '../../icons/home.js';

export const HomeIcon = /*#__PURE__*/ createIcon(icon);
export default HomeIcon;
glyphfox/package.json
"sideEffects": [
  "./dist/web/index.js",
  "./dist/web/glyphfox.min.js",
  "./dist/runtime/auto.js",
  "*.css"
]

The files with side effects are the ones meant to do work on import: glyphfox/web defines the custom element with every icon, the CDN file sets up everything for a page without a build, and glyphfox/runtime replaces data-glyph elements.

Measured sizes

An example measurement of the current build, made with esbuild 0.28: minified ESM with code splitting, counting only the chunks loaded up front, with React marked as external. Treat it as a guide to proportions, not as a promise; your bundler and your own code will move the numbers.

What the app importsMinifiedGzip
One React icon plus the shared runtime76.0 KB27.6 KB
10 React icons79.3 KB29.7 KB
50 React icons90.9 KB37.9 KB
import * as Icons with a dynamic key (every icon)214.7 KBnot measured
renderToString from glyphfox/core plus one icon37.4 KB15.2 KB
toSvg from the main glyphfox entry153.8 KB45.0 KB
The CDN file glyphfox.min.js, no build step216.4 KB62.4 KB
sprite.svg with every icon117.9 KB15.7 KB

Two things stand out. The runtime is the fixed part: rendering, the three axes, modifiers, fusion, morph and the animation presets are shared by every icon and cost the same for one icon or fifty. Each extra icon is cheap, about 0.2 KB gzip on average between the 10 and 50 icon builds. A Vite 8.3.5 test build agreed: the entry chunk with HomeIcon came to 81.5 KB minified and 27.7 KB gzip.

Icons requested by name

Icon with a name, morphTo and Fusion accept names as strings. When a name has not been imported anywhere, the engine loads it on demand from the icon folder:

glyphfox/dist/core/lookup.js
let loader = (name) => import(`../icons/${name}.js`).then((m) => m.default);

What happens to that import depends on the bundler. webpack (and so Next.js) and esbuild cannot know which names will be asked for, so they prepare a chunk for every file that matches: one per icon, a few hundred bytes each, downloaded only when a page renders a name that was not imported. Vite does not transform dynamic imports inside node_modules: its production build emits no icon chunks and keeps the import as written, so a name that was neither imported nor registered stays empty in production, while it works in development, where the dependency is pre-bundled.

If an app really needs any name at runtime, such as an icon picker, opt glyphfox back into the transform. Vite then emits the per icon chunks and prints an INEFFECTIVE_DYNAMIC_IMPORT warning for each icon that is also imported directly, which is harmless:

vite.config.js
export default {
  build: {
    dynamicImportVarsOptions: { exclude: [/node_modules\/(?!glyphfox\/)/] },
  },
};

When you know the names in advance, register their data and nothing is fetched at runtime, with any bundler. If you serve icon data yourself, replace the loader; the default one stays in the code, so webpack still emits the chunks, but the app will not request them:

icons.js
import { registerIcons, setIconLoader } from 'glyphfox/core';
import { iconSettings, iconUsers } from 'glyphfox';

registerIcons([iconSettings, iconUsers]);

// optional: load other names from one JSON file you host (a copy of glyphfox/icons.json)
let all;
setIconLoader(async (name) => {
  all ??= await fetch('/icons.json').then((r) => r.json());
  return all[name] ?? null;
});

Shipping a fixed subset

Pages without a bundler, design system packages and native apps often want a fixed list of icons with fixed props. The CLI builds exactly that, from names on the command line or from a file with one name per line:

terminal
# a sprite with only these icons
npx glyphfox build home search file+check --type sprite --out public/icons

# a CSS file with one mask class per icon
npx glyphfox build home search bell --type css --out public/icons

# a small package of SVG strings, with the props baked in
npx glyphfox build --from icons.txt --type package --name @acme/icons --weight 1.5 --out acme-icons

A subset sprite or CSS file contains no engine at all, which beats any bundle when the icons never change. For pages that load the CDN file, a subset is usually the biggest single saving: the full file carries every icon for convenience. The SVG sprite guide and the HTML and CDN guide cover both setups.

Check your own bundle

Icon data keeps its name and body keys after minification, which makes a quick count possible without any analyzer. In a Vite build the icons you import directly sit in the entry chunk, next to the placeholder named empty that the runtime draws while a name loads:

terminal
npx vite build
grep -ohE 'name:["`][a-z0-9-]+["`],body' dist/assets/index-*.js | grep -v empty | sort -u | wc -l

The number should match the icons you import. If it is close to the size of the whole set, one of the patterns below is pulling everything in. Other bundlers may move shared modules into separate chunks, so with them run the same count over every file the page loads up front, and leave out the lazy icon chunks.

Pitfalls

import * with a dynamic key
Icons[name] on a namespace import forces the bundler to keep every icon. Render <Icon name={name} /> instead, which loads only the names it meets.
CommonJS require
require('glyphfox/react') resolves to the bundled index.cjs, one file with every icon that no bundler can split. Make sure your app code uses import so the ESM build is picked.
toSvg on the client
toSvg takes names, so it needs the full registry and brings every icon along. In browser code, use renderToString from glyphfox/core with imported icon data. On the server the size does not matter.
esbuild without splitting
Without splitting: true esbuild inlines every module the dynamic loader can reach: one icon came to 201 KB minified in a single file instead of 76 KB. The same happens with any output that inlines dynamic imports, such as an IIFE or UMD library build.
glyphfox/web for a few icons
That entry defines the element with the whole set on purpose. For a subset, use defineGlyphFox from glyphfox/web/element and registerIcons.
Judging size in development
Dev servers load every module of a barrel file. Deep imports such as glyphfox/react/icons/home make development faster, but the production build is what counts for size.

Questions

Why does my build output contain hundreds of tiny files?

With webpack or esbuild, they are the lazy chunks of the on demand loader, one per icon, prepared because a name could be requested as a string at runtime. They stay on the server unless a page renders a name that was not imported, so they cost nothing for users. Registering your names with registerIcons means they are never requested. A Vite production build does not create them, as explained in icons requested by name.

How much does each extra icon cost?

In the measurement above, about 0.2 KB gzip per icon on top of the shared runtime of roughly 28 KB gzip. The runtime is paid once, so the second icon is much cheaper than the first.

Icons in this guide