diff --git a/.changeset/add-octicons-react-symbols-package.md b/.changeset/add-octicons-react-symbols-package.md
new file mode 100644
index 00000000000..54711a4237c
--- /dev/null
+++ b/.changeset/add-octicons-react-symbols-package.md
@@ -0,0 +1,5 @@
+---
+'@primer/octicons-react-symbols': minor
+---
+
+Octicons React Symbols: Add a package that provides reusable SVG symbol sprites and matching icon components.
diff --git a/.prettierignore b/.prettierignore
index a7d223c884b..37a12d2e790 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -7,3 +7,4 @@ docs/.cache
docs/public
**/.next/**
packages/react/src/legacy-theme/ts/color-schemes.ts
+packages/octicons-react-symbols/src/generated
diff --git a/eslint.config.mjs b/eslint.config.mjs
index e3534d09a44..ccde67b83c5 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -35,26 +35,27 @@ const github = githubPlugin.getFlatConfigs()
*/
const config = defineConfig([
globalIgnores([
- '.agents/**/*',
+ '**/*.module.css.d.ts',
'**/.cache',
- 'coverage/**/*',
- 'docs/public/**/*',
- 'types/**/*',
+ '**/.next/**/*',
+ '**/.playwright/**',
+ '**/CHANGELOG.md',
+ '**/dist/**/*',
+ '**/node_modules/**/*',
+ '**/storybook-static',
+ '**/storybook-static/**/*',
+ '.agents/**/*',
'consumer-test/**/*',
'contributor-docs/adrs/*',
+ 'coverage/**/*',
+ 'dist/**/*',
+ 'docs/public/**/*',
'examples/codesandbox/**/*',
+ 'packages/octicons-react-symbols/src/generated/**/*',
'packages/react/src/utils/polymorphic.ts',
'packages/styled-react/src/polymorphic.ts',
- '**/storybook-static',
- '**/CHANGELOG.md',
- '**/node_modules/**/*',
- '**/storybook-static/**/*',
- '**/.next/**/*',
- 'dist/**/*',
- '**/dist/**/*',
'script/**/*.ts',
- '**/*.module.css.d.ts',
- '**/.playwright/**',
+ 'types/**/*',
]),
js.configs.recommended,
diff --git a/package-lock.json b/package-lock.json
index bcb66fcfd6f..ceb440fe9a9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -497,6 +497,23 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/@babel/helper-define-polyfill-provider": {
+ "version": "0.6.8",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz",
+ "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "debug": "^4.4.3",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.22.11"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
"node_modules/@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
@@ -879,6 +896,37 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/plugin-transform-runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz",
+ "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "babel-plugin-polyfill-corejs2": "^0.4.14",
+ "babel-plugin-polyfill-corejs3": "^0.13.0",
+ "babel-plugin-polyfill-regenerator": "^0.6.5",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-runtime/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/@babel/plugin-transform-typescript": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz",
@@ -6558,7 +6606,9 @@
"link": true
},
"node_modules/@primer/octicons": {
- "version": "19.15.5",
+ "version": "19.29.1",
+ "resolved": "https://registry.npmjs.org/@primer/octicons/-/octicons-19.29.1.tgz",
+ "integrity": "sha512-XVRArUP8bN+IEjfNQSquXAJinVqAWRx36sVvhN5VzC+gOLfbx16EfFFvcFhSVaqQJ+57PjBrbj75oqm8VlOoYQ==",
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1"
@@ -6576,6 +6626,10 @@
"react": ">=16.3"
}
},
+ "node_modules/@primer/octicons-react-symbols": {
+ "resolved": "packages/octicons-react-symbols",
+ "link": true
+ },
"node_modules/@primer/primitives": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-11.5.1.tgz",
@@ -6882,11 +6936,14 @@
"link": true
},
"node_modules/@publint/pack": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.2.tgz",
- "integrity": "sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==",
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.5.tgz",
+ "integrity": "sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "tinyexec": "^1.2.4"
+ },
"engines": {
"node": ">=18"
},
@@ -8138,6 +8195,34 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@rollup/plugin-babel": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-7.1.0.tgz",
+ "integrity": "sha512-h9Y+xYha6p4wKO+FwdiPIkE+eIYCm8MzZPpX1iARIoFBnmKP9CnpT1p9dDf/DTFm6fyN8PmuLyRI5qZgchnitw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.18.6",
+ "@rollup/pluginutils": "^5.0.1",
+ "workerpool": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0",
+ "@types/babel__core": "^7.1.9",
+ "rollup": "^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/babel__core": {
+ "optional": true
+ },
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
@@ -9129,7 +9214,9 @@
}
},
"node_modules/@types/babel__generator": {
- "version": "7.6.3",
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10965,6 +11052,58 @@
"url": "https://dotenvx.com"
}
},
+ "node_modules/babel-plugin-polyfill-corejs2": {
+ "version": "0.4.17",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz",
+ "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-define-polyfill-provider": "^0.6.8",
+ "semver": "^6.3.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-corejs3": {
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz",
+ "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.6.5",
+ "core-js-compat": "^3.43.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-regenerator": {
+ "version": "0.6.8",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz",
+ "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.6.8"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
"node_modules/babel-plugin-react-compiler": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
@@ -12287,6 +12426,20 @@
"hasInstallScript": true,
"license": "MIT"
},
+ "node_modules/core-js-compat": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
+ "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
"node_modules/core-util-is": {
"version": "1.0.2",
"dev": true,
@@ -17676,6 +17829,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lodash.get": {
"version": "4.4.2",
"dev": true,
@@ -22018,14 +22178,14 @@
"license": "MIT"
},
"node_modules/publint": {
- "version": "0.3.15",
- "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.15.tgz",
- "integrity": "sha512-xPbRAPW+vqdiaKy5sVVY0uFAu3LaviaPO3pZ9FaRx59l9+U/RKR1OEbLhkug87cwiVKxPXyB4txsv5cad67u+A==",
+ "version": "0.3.21",
+ "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.21.tgz",
+ "integrity": "sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@publint/pack": "^0.1.2",
- "package-manager-detector": "^1.3.0",
+ "@publint/pack": "^0.1.4",
+ "package-manager-detector": "^1.6.0",
"picocolors": "^1.1.1",
"sade": "^1.8.1"
},
@@ -25297,9 +25457,9 @@
"license": "MIT"
},
"node_modules/tinyexec": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
- "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -27672,6 +27832,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/workerpool": {
+ "version": "9.3.4",
+ "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz",
+ "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"dev": true,
@@ -28332,6 +28499,156 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "packages/octicons-react-symbols": {
+ "name": "@primer/octicons-react-symbols",
+ "version": "0.0.0",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4",
+ "react-compiler-runtime": "^1.0.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.28.5",
+ "@babel/generator": "^7.28.5",
+ "@babel/plugin-transform-runtime": "^7.28.5",
+ "@babel/preset-react": "^7.28.5",
+ "@babel/preset-typescript": "^7.28.5",
+ "@primer/octicons": "^19.21.1",
+ "@primer/react-compiler-check": "^0.0.0",
+ "@primer/vitest-config": "^0.0.0",
+ "@rolldown/plugin-babel": "^0.2.3",
+ "@rollup/plugin-babel": "^7.1.0",
+ "@types/babel__core": "^7.20.5",
+ "@types/babel__generator": "^7.27.0",
+ "@vitejs/plugin-react": "^6.0.2",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "change-case": "^5.4.4",
+ "publint": "^0.3.16",
+ "react": "18.x || 19.x",
+ "react-dom": "18.x || 19.x",
+ "rimraf": "^6.1.3",
+ "rolldown": "^1.1.2",
+ "rolldown-plugin-dts": "^0.26.0",
+ "typescript": "^6.0.3"
+ },
+ "peerDependencies": {
+ "@types/react": "18.x || 19.x",
+ "@types/react-dom": "18.x || 19.x",
+ "react": "18.x || 19.x",
+ "react-dom": "18.x || 19.x"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "packages/octicons-react-symbols/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "packages/octicons-react-symbols/node_modules/brace-expansion": {
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
+ "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "packages/octicons-react-symbols/node_modules/glob": {
+ "version": "13.0.6",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
+ "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "minimatch": "^10.2.2",
+ "minipass": "^7.1.3",
+ "path-scurry": "^2.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "packages/octicons-react-symbols/node_modules/lru-cache": {
+ "version": "11.5.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "packages/octicons-react-symbols/node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "packages/octicons-react-symbols/node_modules/path-scurry": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
+ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "packages/octicons-react-symbols/node_modules/rimraf": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz",
+ "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "glob": "^13.0.3",
+ "package-json-from-dist": "^1.0.1"
+ },
+ "bin": {
+ "rimraf": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"packages/postcss-preset-primer": {
"version": "0.0.0",
"dependencies": {
diff --git a/packages/octicons-react-symbols/.gitignore b/packages/octicons-react-symbols/.gitignore
new file mode 100644
index 00000000000..4150114be5f
--- /dev/null
+++ b/packages/octicons-react-symbols/.gitignore
@@ -0,0 +1 @@
+src/generated/**
diff --git a/packages/octicons-react-symbols/README.md b/packages/octicons-react-symbols/README.md
new file mode 100644
index 00000000000..e71814f2e6e
--- /dev/null
+++ b/packages/octicons-react-symbols/README.md
@@ -0,0 +1,48 @@
+# @primer/octicons-react-symbols
+
+> Optimized React components for rendering Octicons with shared SVG symbols
+
+## Getting started
+
+To install `@primer/octicons-react-symbols` in your project, you will need to
+run the following command using [npm](https://www.npmjs.com/):
+
+```bash
+npm install -S @primer/octicons-react-symbols
+```
+
+## Usage
+
+The `@primer/octicons-react-symbols` package provides Octicon components that
+reference shared SVG symbols instead of re-creating the same SVG markup every
+time an icon is rendered. This is useful in apps that render the same icons many
+times, because the symbol definition can be shared while each icon renders a
+small `` reference.
+
+In your project, add the `OcticonSymbols` component and include any symbol
+references that you would like to include.
+
+```tsx
+import {OcticonSymbols, CheckSymbol} from '@primer/octicons-react-symbols'
+
+function RootLayout() {
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
+```
+
+Downstream components can render icons normally:
+
+```tsx
+import {CheckIcon} from '@primer/octicons-react-symbols'
+
+function Status() {
+ return
+}
+```
diff --git a/packages/octicons-react-symbols/config/vitest/browser/setup.ts b/packages/octicons-react-symbols/config/vitest/browser/setup.ts
new file mode 100644
index 00000000000..429e1e7546c
--- /dev/null
+++ b/packages/octicons-react-symbols/config/vitest/browser/setup.ts
@@ -0,0 +1,7 @@
+import {beforeEach} from 'vitest'
+import {cleanup} from '@testing-library/react'
+import '@testing-library/jest-dom/vitest'
+
+beforeEach(() => {
+ cleanup()
+})
diff --git a/packages/octicons-react-symbols/package.json b/packages/octicons-react-symbols/package.json
new file mode 100644
index 00000000000..be788c97c41
--- /dev/null
+++ b/packages/octicons-react-symbols/package.json
@@ -0,0 +1,64 @@
+{
+ "name": "@primer/octicons-react-symbols",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "exports": {
+ "types": "./dist/generated/index.d.ts",
+ "default": "./dist/generated/index.js"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "node script/build.ts && rolldown -c",
+ "clean": "rimraf dist",
+ "lint:npm": "publint --types",
+ "lint:react-compiler": "node script/lint-react-compiler.ts",
+ "type-check": "tsc --noEmit",
+ "watch": "rolldown -c -w"
+ },
+ "dependencies": {
+ "@babel/runtime": "^7.28.4",
+ "react-compiler-runtime": "^1.0.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.28.5",
+ "@babel/generator": "^7.28.5",
+ "@babel/plugin-transform-runtime": "^7.28.5",
+ "@babel/preset-react": "^7.28.5",
+ "@babel/preset-typescript": "^7.28.5",
+ "@primer/octicons": "^19.21.1",
+ "@primer/react-compiler-check": "^0.0.0",
+ "@primer/vitest-config": "^0.0.0",
+ "@rolldown/plugin-babel": "^0.2.3",
+ "@rollup/plugin-babel": "^7.1.0",
+ "@types/babel__core": "^7.20.5",
+ "@types/babel__generator": "^7.27.0",
+ "@vitejs/plugin-react": "^6.0.2",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "change-case": "^5.4.4",
+ "publint": "^0.3.16",
+ "react": "18.x || 19.x",
+ "react-dom": "18.x || 19.x",
+ "rimraf": "^6.1.3",
+ "rolldown": "^1.1.2",
+ "rolldown-plugin-dts": "^0.26.0",
+ "typescript": "^6.0.3"
+ },
+ "peerDependencies": {
+ "@types/react": "18.x || 19.x",
+ "@types/react-dom": "18.x || 19.x",
+ "react": "18.x || 19.x",
+ "react-dom": "18.x || 19.x"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ },
+ "sideEffects": false
+}
diff --git a/packages/octicons-react-symbols/rolldown.config.js b/packages/octicons-react-symbols/rolldown.config.js
new file mode 100644
index 00000000000..5763f1750ba
--- /dev/null
+++ b/packages/octicons-react-symbols/rolldown.config.js
@@ -0,0 +1,50 @@
+import {defineConfig} from 'rolldown/config'
+import babel from '@rollup/plugin-babel'
+import {dts} from 'rolldown-plugin-dts'
+import packageJson from './package.json' with {type: 'json'}
+
+const external = [
+ ...Object.keys(packageJson.peerDependencies ?? {}),
+ ...Object.keys(packageJson.dependencies ?? {}),
+ ...Object.keys(packageJson.devDependencies ?? {}),
+].map(name => new RegExp(`^${name}(/.*)?`))
+
+export default defineConfig({
+ input: ['./src/generated/index.ts'],
+ external,
+ plugins: [
+ dts({
+ emitDtsOnly: false,
+ oxc: false,
+ sourcemap: false,
+ tsconfig: 'tsconfig.build.json',
+ }),
+ babel({
+ presets: [
+ '@babel/preset-typescript',
+ [
+ '@babel/preset-react',
+ {
+ runtime: 'automatic',
+ },
+ ],
+ ],
+ plugins: [
+ [
+ 'babel-plugin-react-compiler',
+ {
+ target: '18',
+ },
+ ],
+ '@babel/plugin-transform-runtime',
+ ],
+ extensions: ['.js', '.jsx', '.ts', '.tsx'],
+ babelHelpers: 'runtime',
+ }),
+ ],
+ output: {
+ dir: './dist',
+ preserveModules: true,
+ preserveModulesRoot: 'src',
+ },
+})
diff --git a/packages/octicons-react-symbols/script/build.ts b/packages/octicons-react-symbols/script/build.ts
new file mode 100644
index 00000000000..4f9f2e6da12
--- /dev/null
+++ b/packages/octicons-react-symbols/script/build.ts
@@ -0,0 +1,242 @@
+import fs from 'node:fs/promises'
+import path from 'node:path'
+import babel from '@babel/core'
+import {generate} from '@babel/generator'
+import {pascalCase} from 'change-case'
+import data from '@primer/octicons/build/data.json' with {type: 'json'}
+
+const {types: t} = babel
+const SOURCE_DIRECTORY = path.resolve(import.meta.dirname, '../src')
+const GENERATED_DIRECTORY = path.join(SOURCE_DIRECTORY, 'generated')
+
+await fs.mkdir(GENERATED_DIRECTORY, {recursive: true})
+
+// Each icon is around 2-3kB in size, create modules around 100kB in size
+const BUCKET_SIZE = 40
+const modules = partition(Object.values(data), BUCKET_SIZE).map((icons, index) => {
+ const filepath = path.join(GENERATED_DIRECTORY, `icons-${(index + 1).toString().padStart(2, '0')}.tsx`)
+
+ const propsImportSpecifier = t.importSpecifier(
+ t.identifier('OcticonReferenceProps'),
+ t.identifier('OcticonReferenceProps'),
+ )
+ propsImportSpecifier.importKind = 'type'
+
+ const imports = [
+ // import {forwardRef} from 'react'
+ t.importDeclaration(
+ [t.importSpecifier(t.identifier('forwardRef'), t.identifier('forwardRef'))],
+ t.stringLiteral('react'),
+ ),
+
+ // import {Icon} from '../Icon'
+ t.importDeclaration([t.importSpecifier(t.identifier('Icon'), t.identifier('Icon'))], t.stringLiteral('../Icon')),
+
+ // import type {OcticonReferenceProps} from '../types'
+ t.importDeclaration([propsImportSpecifier], t.stringLiteral('../types')),
+ ]
+
+ const components = icons.flatMap(icon => {
+ const symbolName = `${pascalCase(icon.name)}Symbol`
+ const symbols = Object.entries(icon.heights).map(([height, size]) => {
+ const id = `symbol-octicon-${icon.name}-${height}`
+
+ const jsx = t.jsxElement(
+ t.jsxOpeningElement(t.jsxIdentifier('symbol'), [
+ t.jsxAttribute(t.jsxIdentifier('id'), t.stringLiteral(id)),
+ t.jsxAttribute(t.jsxIdentifier('viewBox'), t.stringLiteral(`0 0 ${size.width} ${height}`)),
+ ]),
+ t.jsxClosingElement(t.jsxIdentifier('symbol')),
+ svgToJSX(size.ast),
+ )
+
+ return {
+ id,
+ height,
+ width: size.width,
+ jsx,
+ }
+ })
+ const symbolComponent = t.functionDeclaration(
+ t.identifier(symbolName),
+ [],
+ t.blockStatement([
+ t.returnStatement(
+ symbols.length === 1
+ ? symbols[0].jsx
+ : t.jsxFragment(
+ t.jsxOpeningFragment(),
+ t.jsxClosingFragment(),
+ symbols.map(symbol => symbol.jsx),
+ ),
+ ),
+ ]),
+ )
+
+ const referenceName = `${pascalCase(icon.name)}Icon`
+ const forwardRef = t.callExpression(t.identifier('forwardRef'), [
+ t.functionExpression(
+ t.identifier(referenceName),
+ [t.identifier('props'), t.identifier('ref')],
+ t.blockStatement([
+ t.returnStatement(
+ t.jsxElement(
+ t.jsxOpeningElement(
+ t.jsxIdentifier('Icon'),
+ [
+ t.jsxSpreadAttribute(t.identifier('props')),
+ t.jsxAttribute(t.jsxIdentifier('ref'), t.jsxExpressionContainer(t.identifier('ref'))),
+ t.jsxAttribute(
+ t.jsxIdentifier('sizes'),
+ t.jsxExpressionContainer(
+ t.objectExpression(
+ symbols.map(symbol =>
+ t.objectProperty(
+ t.stringLiteral(symbol.height),
+ t.objectExpression([
+ t.objectProperty(t.stringLiteral('width'), t.numericLiteral(symbol.width)),
+ t.objectProperty(t.stringLiteral('id'), t.stringLiteral(symbol.id)),
+ ]),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ true,
+ ),
+ t.jsxClosingElement(t.jsxIdentifier('Icon')),
+ [],
+ ),
+ ),
+ ]),
+ ),
+ ])
+
+ forwardRef.typeParameters = t.tsTypeParameterInstantiation([
+ t.tsTypeReference(t.identifier('SVGSVGElement')),
+ t.tsTypeReference(t.identifier('OcticonReferenceProps')),
+ ])
+
+ const reference = t.variableDeclaration('const', [t.variableDeclarator(t.identifier(referenceName), forwardRef)])
+
+ return [t.exportNamedDeclaration(symbolComponent), t.exportNamedDeclaration(reference)]
+ })
+
+ const body = [...imports, ...components]
+ const program = t.addComment(
+ t.program(body),
+ 'leading',
+ `This file is auto-generated by 'script/build.ts'. Do not edit directly.`,
+ )
+
+ return {
+ filepath,
+ contents: generate(program).code,
+ exports: icons.flatMap(icon => [`${pascalCase(icon.name)}Icon`, `${pascalCase(icon.name)}Symbol`]),
+ }
+})
+
+for (const mod of modules) {
+ await fs.writeFile(mod.filepath, mod.contents)
+}
+
+const indexFilePath = path.join(SOURCE_DIRECTORY, 'generated', 'index.ts')
+
+const octiconsSymbolsPropsExportSpecifier = t.exportSpecifier(
+ t.identifier('OcticonSymbolsProps'),
+ t.identifier('OcticonSymbolsProps'),
+)
+octiconsSymbolsPropsExportSpecifier.exportKind = 'type'
+
+const octiconsReferencePropsExportSpecifier = t.exportSpecifier(
+ t.identifier('OcticonReferenceProps'),
+ t.identifier('OcticonReferenceProps'),
+)
+octiconsReferencePropsExportSpecifier.exportKind = 'type'
+
+const index = t.addComment(
+ t.program([
+ t.exportNamedDeclaration(
+ null,
+ [
+ t.exportSpecifier(t.identifier('OcticonSymbols'), t.identifier('OcticonSymbols')),
+ octiconsSymbolsPropsExportSpecifier,
+ ],
+ t.stringLiteral('../OcticonSymbols'),
+ ),
+ t.exportNamedDeclaration(null, [octiconsReferencePropsExportSpecifier], t.stringLiteral('../types')),
+ ...modules.map(mod => {
+ return t.exportNamedDeclaration(
+ null,
+ mod.exports.map(exportName => t.exportSpecifier(t.identifier(exportName), t.identifier(exportName))),
+ t.stringLiteral(`./${path.basename(mod.filepath, path.extname(mod.filepath))}`),
+ )
+ }),
+ ]),
+ 'leading',
+ `This file is auto-generated by 'script/build.ts'. Do not edit directly.`,
+)
+
+await fs.writeFile(indexFilePath, generate(index).code)
+
+function partition(items: Array, size: number): Array> {
+ const result: Array> = []
+ let bucket: Array = []
+ let count = 0
+
+ for (const item of items) {
+ if (count >= size) {
+ result.push(bucket)
+ bucket = []
+ count = 0
+ }
+
+ bucket.push(item)
+ count++
+ }
+
+ if (bucket.length > 0) {
+ result.push(bucket)
+ }
+
+ return result
+}
+
+type SVGASTNode = {
+ type: string
+ name: string
+ attributes: Record
+ children: Array
+}
+
+function svgToJSX(node: SVGASTNode): Array {
+ if (node.type === 'element') {
+ const children = node.children.map(svgToJSX)
+
+ if (node.name === 'svg') {
+ if (children.length === 0) {
+ throw new Error(`No children available for icon`)
+ }
+
+ return children.flat()
+ }
+
+ const attrs = Object.entries(node.attributes).map(([key, value]) => {
+ if (typeof value !== 'string') {
+ throw new Error(`Unknown value type: ${value}`)
+ }
+ return t.jsxAttribute(t.jsxIdentifier(key), t.stringLiteral(value))
+ })
+ const openingElement = t.jsxOpeningElement(t.jsxIdentifier(node.name), attrs, children.length === 0)
+ const closingElement = t.jsxClosingElement(t.jsxIdentifier(node.name))
+
+ if (children.length > 0) {
+ return [t.jsxElement(openingElement, closingElement, children.flat(), false)]
+ }
+
+ return [t.jsxElement(openingElement, closingElement, [], true)]
+ }
+
+ throw new Error(`Unknown type: ${node.type}`)
+}
diff --git a/packages/octicons-react-symbols/script/lint-react-compiler.ts b/packages/octicons-react-symbols/script/lint-react-compiler.ts
new file mode 100644
index 00000000000..4de57bd59dc
--- /dev/null
+++ b/packages/octicons-react-symbols/script/lint-react-compiler.ts
@@ -0,0 +1,56 @@
+import {globSync, readFileSync} from 'node:fs'
+import path from 'node:path'
+import {checkFile} from '@primer/react-compiler-check'
+import type {CheckError, CheckResult} from '@primer/react-compiler-check'
+
+type CompilerFailure = Extract & {
+ filepath: string
+}
+
+const packageDirectory = path.resolve(import.meta.dirname, '..')
+const files = globSync('src/**/*.tsx', {
+ cwd: packageDirectory,
+ exclude: ['src/**/__tests__/**'],
+}).map(filepath => path.join(packageDirectory, filepath))
+const failures = files.map(checkCompilerFile).filter((result): result is CompilerFailure => !result.ok)
+
+if (failures.length > 0) {
+ // eslint-disable-next-line no-console
+ console.error(`React Compiler failed for ${failures.length} file${failures.length === 1 ? '' : 's'}:\n`)
+
+ for (const failure of failures) {
+ const relativePath = path.relative(packageDirectory, failure.filepath)
+
+ for (const error of failure.errors) {
+ const line = getLocationLine(error.location)
+ const location = line === undefined ? relativePath : `${relativePath}:${line}`
+
+ // eslint-disable-next-line no-console
+ console.error(`- ${location} ${formatReason(error.reason)}`)
+ }
+ }
+
+ process.exitCode = 1
+} else {
+ // eslint-disable-next-line no-console
+ console.log(`React Compiler passed for ${files.length} files.`)
+}
+
+function formatReason(reason: string): string {
+ return reason.replaceAll('\n', ' ')
+}
+
+function getLocationLine(location: CheckError['location']): number | undefined {
+ if (location === null || typeof location !== 'object' || !('start' in location)) {
+ return undefined
+ }
+
+ return location.start.line
+}
+
+function checkCompilerFile(filepath: string): CheckResult & {filepath: string} {
+ return {
+ filepath,
+ ...checkFile(filepath, readFileSync(filepath, 'utf8')),
+ }
+}
diff --git a/packages/octicons-react-symbols/src/Icon.tsx b/packages/octicons-react-symbols/src/Icon.tsx
new file mode 100644
index 00000000000..b6cf554c9b1
--- /dev/null
+++ b/packages/octicons-react-symbols/src/Icon.tsx
@@ -0,0 +1,67 @@
+import {forwardRef, type HTMLAttributes} from 'react'
+import type {OcticonReferenceProps} from './types'
+
+const sizeMap = {
+ small: 16,
+ medium: 32,
+ large: 64,
+}
+
+type Size = 'small' | 'medium' | 'large'
+
+type IconProps = HTMLAttributes &
+ OcticonReferenceProps & {
+ size?: Size | number
+ sizes: Record<
+ string,
+ {
+ id: string
+ width: number
+ }
+ >
+ }
+
+const Icon = forwardRef(function Icon(
+ {'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, size = 16, sizes, tabIndex, title, ...rest},
+ ref,
+) {
+ const height = typeof size === 'number' ? size : sizeMap[size]
+ const heights = Object.keys(sizes)
+ const naturalHeight = closestNaturalHeight(heights, height)
+ const naturalWidth = sizes[naturalHeight].width
+ const width = height * (naturalWidth / naturalHeight)
+ const id = sizes[naturalHeight].id
+ const labelled = ariaLabel || ariaLabelledBy
+ const role = labelled ? 'img' : undefined
+
+ return (
+
+ )
+})
+
+function closestNaturalHeight(naturalHeights: Array, height: number): number {
+ const parsed = naturalHeights.map(naturalHeight => parseInt(naturalHeight, 10))
+
+ return parsed.reduce((acc, naturalHeight) => {
+ return naturalHeight <= height ? naturalHeight : acc
+ }, parsed[0])
+}
+
+export {Icon}
+export type {IconProps}
diff --git a/packages/octicons-react-symbols/src/OcticonSymbols.tsx b/packages/octicons-react-symbols/src/OcticonSymbols.tsx
new file mode 100644
index 00000000000..ba17d12746a
--- /dev/null
+++ b/packages/octicons-react-symbols/src/OcticonSymbols.tsx
@@ -0,0 +1,44 @@
+import {createContext, Fragment, useMemo, useState} from 'react'
+import type {PropsWithChildren} from 'react'
+
+type OcticonSymbolsProps = PropsWithChildren
+
+type OcticonSymbolsContextValue = {
+ registerSymbol(id: string, symbol: React.ReactNode): void
+}
+
+const OcticonSymbolsContext = createContext(null)
+
+function OcticonSymbols({children}: OcticonSymbolsProps) {
+ const [symbols, setSymbols] = useState