react-icons-sprite 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,30 @@
1
1
  # react-icons-sprite
2
2
 
3
- `react-icons-sprite` is a lightweight plugin for Vite and Webpack, built on top of [react-icons](https://github.com/react-icons/react-icons). It automatically detects the `react-icons` components you use, generates a single SVG spritesheet containing them, and rewrites your code to reference those symbols via `<use>`. This approach both shrinks your bundle (no more inlined React components for every icon) and reduces runtime overhead, since React no longer has to reconcile large, nested SVG trees.
3
+ `react-icons-sprite` is a lightweight plugin for Vite and Webpack that turns React icon components into a single SVG spritesheet and rewrites your code to reference those symbols via `<use>`.
4
+
5
+ It supports multiple React icon packages that export icons as individual React components. This approach both shrinks your bundle (no more inlined React components for every icon) and reduces runtime overhead, since React no longer has to reconcile large, nested SVG trees.
6
+
7
+ ## Supported icon libraries
8
+
9
+ Out of the box, imports from the following libraries are detected and transformed:
10
+
11
+ - `react-icons/*` packs (e.g. `react-icons/bi`, `react-icons/fa`, ...)
12
+ - `lucide-react`
13
+ - `@radix-ui/react-icons`
14
+ - `@heroicons/react` (v1 and v2 subpaths)
15
+ - `@tabler/icons-react`
16
+ - `phosphor-react`
17
+ - `@phosphor-icons/react`
18
+ - `react-feather`
19
+ - `react-bootstrap-icons`
20
+ - `grommet-icons`
21
+ - `remixicon-react`
22
+ - `@remixicon/react`
23
+ - `devicons-react`
4
24
 
5
25
  ## Motivation
6
26
 
7
- By default, when you use `react-icons`, each icon is a React component. For example:
27
+ By default, when you use an icon library like `react-icons`, each icon is a React component. For example:
8
28
 
9
29
  ```tsx
10
30
  import { LuWheat } from "react-icons/lu";
@@ -64,7 +84,7 @@ export function Example() {
64
84
  import { b as n } from './icon-FONPSuqX.js';
65
85
 
66
86
  var r = e(t());
67
- const i = () => (0, r.jsx)(n, { iconId: `ri-LuWheat` });
87
+ const i = () => (0, r.jsx)(n, { iconId: `ri-react-icons-lu-LuWheat` });
68
88
  export { i as IconWheat };
69
89
  ```
70
90
 
@@ -93,6 +113,26 @@ Runtime difference:
93
113
 
94
114
  This is a big win when you’re rendering icons in lists, tables, or maps where dozens or hundreds of them appear at once.
95
115
 
116
+ ### Performance comparison
117
+
118
+ | icon (pack) | react-icons icon render mean time | react-icons-sprite icon render mean time | Relative difference |
119
+ |--------------------|----------------------------------:|---------------------------------------------:|------------------------:|
120
+ | **FiCpu** (fi) | 0.188 ms | 0.048 ms | 74.6% reduction |
121
+ | **MdBuild** (md) | 0.198 ms | 0.048 ms | 76.0% reduction |
122
+ | **FaCamera** (fa) | 0.162 ms | 0.015 ms | 90.7% reduction |
123
+ | **IoAperture** (io5)| 0.029 ms | 0.015 ms | 49.7% reduction |
124
+ | **BiBell** (bi) | 0.023 ms | 0.014 ms | 38.5% reduction |
125
+ | **AiOutlineAlert** (ai) | 0.023 ms | 0.014 ms | 38.3% reduction |
126
+ | **BsAlarm** (bs) | 0.027 ms | 0.014 ms | 47.4% reduction |
127
+ | **RiAnchor** (ri) | 0.023 ms | 0.014 ms | 38.6% reduction |
128
+ | **CgArrows** (cg) | 0.029 ms | 0.014 ms | 52.0% reduction |
129
+ | **HiAcademicCap** (hi) | 0.023 ms | 0.014 ms | 38.6% reduction |
130
+ | **SiTypescript** (si) | 0.023 ms | 0.014 ms | 39.7% reduction |
131
+ | **TiThLarge** (ti) | 0.023 ms | 0.014 ms | 40.0% reduction |
132
+
133
+ * **Test details / machine:** **Lenovo Legion 5 Pro 16ACH6H** (Ryzen 7 5800H — 8 cores / 16 threads, base ≈ 3.2 GHz, turbo ≈ 4.4 GHz, DDR4-3200 memory); Node.js v24.10.0.
134
+ * Differences will vary based on icons used in your application, but they will generally be the range of 50-75% reduction in render time. Larger icons will generate a larger difference.
135
+
96
136
  ## Installation
97
137
 
98
138
  Install the plugin via npm or yarn:
@@ -115,7 +155,7 @@ export default defineConfig({
115
155
  ```
116
156
 
117
157
  ### Webpack
118
- Add the loader to transform modules that import from `react-icons/*` and install the plugin to emit the sprite and rewrite the placeholder URL.
158
+ Add the loader to transform modules that import icons and install the plugin to emit the sprite and rewrite the placeholder URL.
119
159
 
120
160
  ```js
121
161
  // webpack.config.js (v5)
@@ -144,18 +184,14 @@ module.exports = {
144
184
  // optional: fileName: 'icons.svg'
145
185
  }),
146
186
  ],
147
- output: {
148
- // ensure your publicPath is set correctly if you deploy under a sub-path
149
- // publicPath: '/',
150
- },
151
187
  };
152
188
  ```
153
189
 
154
190
  ## How it works
155
191
 
156
- In **development mode**, the plugin does nothing special. Icons are rendered as they normally would from `react-icons`. This keeps hot module replacement (HMR) snappy — there’s no extra parsing of the codebase or regenerating of the sprite on every save. If the plugin were to build the sprite during dev, it would need to constantly scan for `react-icons` imports and rebuild the sheet, which is expensive and slows down iteration. So, in dev, you get the normal `react-icons` behavior.
192
+ In **development mode**, the plugin does nothing special. Icons are rendered as they normally would from your icon library. This keeps hot module replacement (HMR) snappy — there’s no extra parsing of the codebase or regenerating of the sprite on every save. If the plugin were to build the sprite during dev, it would need to constantly scan for icon imports and rebuild the sheet, which is expensive and slows down iteration. So, in dev, you get the normal component behavior.
157
193
 
158
- In **build mode**, the plugin transforms your code. It parses each module, looks for imports from `react-icons/*`, and rewrites the JSX. Instead of rendering full inline `<svg>` trees, it replaces them with `<ReactIconsSpriteIcon iconId="..." />`. While doing this, it collects every unique icon used across the project. After the bundling step, the plugin renders all those icons once to static markup and generates a single SVG file containing `<symbol>` definitions for each one. Finally, it rewrites your bundle to point every `<ReactIconsSpriteIcon>` at that spritesheet using a `<use>` tag.
194
+ In **build mode**, the plugin transforms your code. It parses each module, looks for imports from supported React icon packages, and rewrites the JSX. Instead of rendering full inline `<svg>` trees, it replaces them with `<ReactIconsSpriteIcon iconId="..." />`. While doing this, it collects every unique icon used across the project. After the bundling step, the plugin renders all those icons once to static markup and generates a single SVG file containing `<symbol>` definitions for each one. Finally, it rewrites your bundle to point every `<ReactIconsSpriteIcon>` at that spritesheet using a `<use>` tag.
159
195
 
160
196
  The result: during development you keep fast feedback loops, and in production you ship a single optimized sprite file with lightweight `<use>` references.
161
197
 
@@ -0,0 +1,7 @@
1
+ import { r as createCollector } from "./core-BoovxbBC.mjs";
2
+
3
+ //#region src/collector.ts
4
+ const collector = createCollector();
5
+
6
+ //#endregion
7
+ export { collector as t };
@@ -8,9 +8,30 @@ import _generate from "@babel/generator";
8
8
  //#region src/core.ts
9
9
  const traverse = _traverse.default ?? _traverse;
10
10
  const generate = _generate.default ?? _generate;
11
- const PLACEHOLDER = "__SPRITE_URL_PLACEHOLDER__";
12
11
  const ICON_SOURCE = "react-icons-sprite";
13
12
  const ICON_COMPONENT_NAME = "ReactIconsSpriteIcon";
13
+ const DEFAULT_ICON_SOURCES = [
14
+ /^react-icons\/[\w-]+$/,
15
+ /^lucide-react$/,
16
+ /^@radix-ui\/react-icons$/,
17
+ /^@heroicons\/react(?:\/.*)?$/,
18
+ /^@tabler\/icons-react$/,
19
+ /^phosphor-react$/,
20
+ /^@phosphor-icons\/react$/,
21
+ /^react-feather$/,
22
+ /^react-bootstrap-icons$/,
23
+ /^grommet-icons$/,
24
+ /^remixicon-react$/,
25
+ /^@remixicon\/react$/,
26
+ /^devicons-react$/
27
+ ];
28
+ const sourceMatchesSupported = (source, sources = DEFAULT_ICON_SOURCES) => sources.some((re) => re.test(source));
29
+ const normalizeAlias = (pack) => {
30
+ return pack.replace(/^@/, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
31
+ };
32
+ const computeIconId = (pack, exportName) => {
33
+ return `ri-${normalizeAlias(pack)}-${exportName}`;
34
+ };
14
35
  const parseAst = (code, filename = "module.tsx") => {
15
36
  return parse(code, {
16
37
  sourceType: "module",
@@ -18,9 +39,9 @@ const parseAst = (code, filename = "module.tsx") => {
18
39
  sourceFilename: filename
19
40
  });
20
41
  };
21
- const collectReactIconImports = (ast) => {
42
+ const collectIconImports = (ast, sources = DEFAULT_ICON_SOURCES) => {
22
43
  const map = /* @__PURE__ */ new Map();
23
- for (const node of ast.program.body) if (t.isImportDeclaration(node) && /^react-icons\/[\w-]+$/.test(node.source.value) && node.importKind !== "type") {
44
+ for (const node of ast.program.body) if (t.isImportDeclaration(node) && sourceMatchesSupported(node.source.value, sources) && node.importKind !== "type") {
24
45
  const pack = node.source.value;
25
46
  for (const spec of node.specifiers) if (t.isImportSpecifier(spec) && t.isIdentifier(spec.imported) && t.isIdentifier(spec.local) && spec.importKind !== "type") {
26
47
  const exportName = spec.imported.name;
@@ -64,7 +85,10 @@ const replaceJsxWithSprite = (ast, localNameToImport, iconLocalName, register) =
64
85
  if (!meta) return;
65
86
  if (isAlreadyIcon(name)) return;
66
87
  path.node.name = t.jSXIdentifier(iconLocalName);
67
- if (!path.node.attributes.some((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name, { name: "iconId" }))) path.node.attributes.unshift(t.jSXAttribute(t.jSXIdentifier("iconId"), t.stringLiteral(`ri-${meta.exportName}`)));
88
+ if (!path.node.attributes.some((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name, { name: "iconId" }))) {
89
+ const idValue = computeIconId(meta.pack, meta.exportName);
90
+ path.node.attributes.unshift(t.jSXAttribute(t.jSXIdentifier("iconId"), t.stringLiteral(idValue)));
91
+ }
68
92
  usedLocalNames.add(local);
69
93
  anyReplacements = true;
70
94
  register(meta.pack, meta.exportName);
@@ -105,9 +129,9 @@ const generateCode = (ast, origCode, id) => {
105
129
  map
106
130
  };
107
131
  };
108
- const transformModule = (code, id, register) => {
132
+ const transformModule = (code, id, register, sources = DEFAULT_ICON_SOURCES) => {
109
133
  const ast = parseAst(code, id);
110
- const localNameToImport = collectReactIconImports(ast);
134
+ const localNameToImport = collectIconImports(ast, sources);
111
135
  if (localNameToImport.size === 0) return {
112
136
  code,
113
137
  map: null,
@@ -151,7 +175,7 @@ const renderOneIcon = async (pack, exportName) => {
151
175
  pack
152
176
  ))[exportName];
153
177
  if (!Comp) throw new Error(`Icon export not found: ${pack} -> ${exportName}`);
154
- const id = `ri-${exportName}`;
178
+ const id = computeIconId(pack, exportName);
155
179
  const html = renderToStaticMarkup(createElement(Comp));
156
180
  const viewBox = html.match(/viewBox="([^"]+)"/i)?.[1] ?? "0 0 24 24";
157
181
  const svgAttrsRaw = html.match(/^<svg\b([^>]*)>/i)?.[1] ?? "";
@@ -188,4 +212,4 @@ const createCollector = () => {
188
212
  };
189
213
 
190
214
  //#endregion
191
- export { transformModule as i, buildSprite as n, createCollector as r, PLACEHOLDER as t };
215
+ export { transformModule as i, buildSprite as n, createCollector as r, DEFAULT_ICON_SOURCES as t };
package/dist/icon.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
2
 
3
3
  //#region src/icon.tsx
4
- const ReactIconsSpriteIcon = ({ iconId,...rest }) => {
4
+ const ReactIconsSpriteIcon = ({ iconId, ...rest }) => {
5
5
  const iconHref = `__SPRITE_URL_PLACEHOLDER__#${iconId}`;
6
6
  return /* @__PURE__ */ jsx("svg", {
7
7
  height: "1em",
@@ -0,0 +1,4 @@
1
+ //#region src/index.d.ts
2
+ declare const REACT_ICONS_SPRITE_URL_PLACEHOLDER = "__SPRITE_URL_PLACEHOLDER__";
3
+ //#endregion
4
+ export { REACT_ICONS_SPRITE_URL_PLACEHOLDER };
package/dist/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ import { t as REACT_ICONS_SPRITE_URL_PLACEHOLDER } from "./src-Bf4uZBwp.mjs";
2
+
3
+ export { REACT_ICONS_SPRITE_URL_PLACEHOLDER };
@@ -0,0 +1,5 @@
1
+ //#region src/index.ts
2
+ const REACT_ICONS_SPRITE_URL_PLACEHOLDER = "__SPRITE_URL_PLACEHOLDER__";
3
+
4
+ //#endregion
5
+ export { REACT_ICONS_SPRITE_URL_PLACEHOLDER as t };
@@ -1,4 +1,5 @@
1
- import { i as transformModule, n as buildSprite, r as createCollector, t as PLACEHOLDER } from "../core-CcX_8jJU.mjs";
1
+ import { t as REACT_ICONS_SPRITE_URL_PLACEHOLDER } from "../src-Bf4uZBwp.mjs";
2
+ import { i as transformModule, n as buildSprite, r as createCollector, t as DEFAULT_ICON_SOURCES } from "../core-BoovxbBC.mjs";
2
3
  import { createHash } from "node:crypto";
3
4
 
4
5
  //#region src/vite/plugin.ts
@@ -15,11 +16,10 @@ const reactIconsSprite = (options = {}) => {
15
16
  transform(code, id) {
16
17
  const cleanId = id.split("?", 1)[0];
17
18
  if (!/\.(mjs|cjs|js|jsx|ts|tsx)$/.test(cleanId)) return null;
18
- if (!/from\s+['"]react-icons\//.test(code)) return null;
19
19
  try {
20
20
  const { code: next, map, anyReplacements } = transformModule(code, id, (pack, exportName) => {
21
21
  collector.add(pack, exportName);
22
- });
22
+ }, DEFAULT_ICON_SOURCES);
23
23
  if (!anyReplacements) return null;
24
24
  return {
25
25
  code: next,
@@ -35,14 +35,13 @@ const reactIconsSprite = (options = {}) => {
35
35
  const generatedHash = createHash("sha256").update(spriteXml).digest("hex").slice(0, 8);
36
36
  const emitFileOptions = {
37
37
  type: "asset",
38
- source: spriteXml
38
+ source: spriteXml,
39
+ fileName: fileName ? fileName : `react-icons-sprite-${generatedHash}.svg`
39
40
  };
40
- if (fileName) emitFileOptions.fileName = fileName;
41
- else emitFileOptions.name = "react-icons-sprite.svg";
42
41
  const assetId = this.emitFile(emitFileOptions);
43
- const finalUrl = `/${this.getFileName(assetId)}?v=${encodeURIComponent(generatedHash)}`;
42
+ const finalUrl = `/${this.getFileName(assetId)}`;
44
43
  for (const [, item] of Object.entries(bundle)) if (item.type === "chunk" && typeof item.code === "string") {
45
- if (item.code.includes(PLACEHOLDER)) item.code = item.code.replaceAll(PLACEHOLDER, finalUrl);
44
+ if (item.code.includes(REACT_ICONS_SPRITE_URL_PLACEHOLDER)) item.code = item.code.replaceAll(REACT_ICONS_SPRITE_URL_PLACEHOLDER, finalUrl);
46
45
  }
47
46
  }
48
47
  };
@@ -1,5 +1,5 @@
1
- import { i as transformModule } from "../core-CcX_8jJU.mjs";
2
- import { t as collector } from "../collector-CbA7qCnf.mjs";
1
+ import { i as transformModule } from "../core-BoovxbBC.mjs";
2
+ import { t as collector } from "../collector-DrPR8kXO.mjs";
3
3
 
4
4
  //#region src/webpack/loader.ts
5
5
  const reactIconsSpriteLoader = async function(source) {
@@ -4,7 +4,7 @@ import { Compiler, WebpackPluginInstance } from "webpack";
4
4
  type ReactIconsSpriteWebpackPluginOptions = {
5
5
  /**
6
6
  * If passed, this exact string will be used for the emitted file name.
7
- * If fileName is omitted, name will be generated as `react-icons-sprite.svg`.
7
+ * If fileName is omitted, name will be generated as `react-icons-sprite-[hash].svg`.
8
8
  * This is useful when, for example, multiple sprite sheets are generated during client and server builds.
9
9
  */
10
10
  fileName?: string;
@@ -1,5 +1,6 @@
1
- import { n as buildSprite, t as PLACEHOLDER } from "../core-CcX_8jJU.mjs";
2
- import { t as collector } from "../collector-CbA7qCnf.mjs";
1
+ import { t as REACT_ICONS_SPRITE_URL_PLACEHOLDER } from "../src-Bf4uZBwp.mjs";
2
+ import { n as buildSprite } from "../core-BoovxbBC.mjs";
3
+ import { t as collector } from "../collector-DrPR8kXO.mjs";
3
4
  import { createHash } from "node:crypto";
4
5
 
5
6
  //#region src/webpack/plugin.ts
@@ -19,7 +20,7 @@ var ReactIconsSpriteWebpackPlugin = class {
19
20
  }, async () => {
20
21
  const spriteXml = await buildSprite(collector.toList());
21
22
  const generatedHash = createHash("sha256").update(spriteXml).digest("hex").slice(0, 8);
22
- const name = this.fileName ?? "react-icons-sprite.svg";
23
+ const name = this.fileName ?? `react-icons-sprite-${generatedHash}.svg`;
23
24
  const RawSource = compiler.webpack?.sources?.RawSource;
24
25
  if (!RawSource) throw new Error("[react-icons-sprite] Unable to access webpack RawSource");
25
26
  compilation.emitAsset(name, new RawSource(spriteXml));
@@ -27,14 +28,14 @@ var ReactIconsSpriteWebpackPlugin = class {
27
28
  let base = "";
28
29
  if (typeof outputPublicPath === "string" && outputPublicPath !== "auto") base = outputPublicPath.endsWith("/") ? outputPublicPath : `${outputPublicPath}/`;
29
30
  else base = "/";
30
- const finalUrl = `${base}${name}?v=${encodeURIComponent(generatedHash)}`;
31
+ const finalUrl = `${base}${name}`;
31
32
  for (const asset of compilation.getAssets()) {
32
33
  const filename = asset.name;
33
34
  if (!/\.(js|mjs|cjs)$/i.test(filename)) continue;
34
35
  const src = asset.source.source();
35
36
  if (typeof src !== "string") continue;
36
- if (!src.includes(PLACEHOLDER)) continue;
37
- const next = src.replaceAll(PLACEHOLDER, finalUrl);
37
+ if (!src.includes(REACT_ICONS_SPRITE_URL_PLACEHOLDER)) continue;
38
+ const next = src.replaceAll(REACT_ICONS_SPRITE_URL_PLACEHOLDER, finalUrl);
38
39
  compilation.updateAsset(filename, new RawSource(next));
39
40
  }
40
41
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-icons-sprite",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "",
@@ -11,9 +11,9 @@
11
11
  "sideEffects": false,
12
12
  "exports": {
13
13
  ".": {
14
- "types": "./dist/icon.d.mts",
15
- "import": "./dist/icon.mjs",
16
- "default": "./dist/icon.mjs"
14
+ "types": "./dist/index.d.mts",
15
+ "import": "./dist/index.mjs",
16
+ "default": "./dist/index.mjs"
17
17
  },
18
18
  "./vite": {
19
19
  "types": "./dist/vite/plugin.d.mts",
@@ -29,11 +29,6 @@
29
29
  "types": "./dist/webpack/loader.d.mts",
30
30
  "import": "./dist/webpack/loader.mjs",
31
31
  "default": "./dist/webpack/loader.mjs"
32
- },
33
- "./turbopack": {
34
- "types": "./dist/turbopack/plugin.d.mts",
35
- "import": "./dist/turbopack/plugin.mjs",
36
- "default": "./dist/turbopack/plugin.mjs"
37
32
  }
38
33
  },
39
34
  "publishConfig": {
@@ -52,9 +47,12 @@
52
47
  "scripts": {
53
48
  "build": "tsdown",
54
49
  "dev": "tsdown --watch",
55
- "format": "biome format . --write",
56
- "lint": "biome lint .",
57
- "lint:fix": "biome lint . --write",
50
+ "format": "biome format --no-errors-on-unmatched",
51
+ "format:fix": "biome format --write --no-errors-on-unmatched",
52
+ "lint": "biome lint --no-errors-on-unmatched",
53
+ "lint:fix": "biome lint --write --no-errors-on-unmatched",
54
+ "type-check": "tsgo",
55
+ "test": "vitest run",
58
56
  "prepublishOnly": "npm run build",
59
57
  "release": "npm publish --access public"
60
58
  },
@@ -68,18 +66,20 @@
68
66
  "@babel/parser": "7.28.5",
69
67
  "@babel/traverse": "7.28.5",
70
68
  "@babel/types": "7.28.5",
71
- "@biomejs/biome": "2.3.5",
69
+ "@biomejs/biome": "2.3.8",
72
70
  "@types/babel__generator": "7.27.0",
73
71
  "@types/babel__traverse": "7.28.0",
74
- "@types/node": "24.9.1",
72
+ "@types/node": "24.10.2",
75
73
  "@types/react-dom": "19.2.3",
76
- "react": "19.2.0",
77
- "react-dom": "19.2.0",
74
+ "@typescript/native-preview": "7.0.0-dev.20251210.1",
75
+ "react": "19.2.1",
76
+ "react-dom": "19.2.1",
78
77
  "react-icons": "5.5.0",
79
- "tsdown": "0.16.3",
78
+ "tsdown": "0.17.2",
80
79
  "typescript": "5.9.3",
81
- "vite": "7.2.2",
82
- "webpack": "5.102.1"
80
+ "vite": "7.2.7",
81
+ "vitest": "4.0.15",
82
+ "webpack": "5.103.0"
83
83
  },
84
84
  "keywords": [
85
85
  "vite",
@@ -1,7 +0,0 @@
1
- import { r as createCollector } from "./core-CcX_8jJU.mjs";
2
-
3
- //#region src/collector.ts
4
- const collector = createCollector();
5
-
6
- //#endregion
7
- export { collector as t };