Five icon systems across 15 repos created visual drift and 84KB bundles in the worst Next.js app.
Phosphor ships six weights from one designer, so the whole studio reads as one product.
Liquid themes use Fill weight, React apps use Regular, never mixed in the same surface.
A 40-icon migration across 15 repos took one afternoon with ripgrep and a small codemod.
Tree-shaken Phosphor lands at 18KB, a 78% cut on the worst case and roughly 60% on average.
I caught the problem on a Tuesday. I had two RAXXO tabs open side by side. One was a Next.js dashboard, the other a Shopify product page. Both had a small rocket icon near the primary CTA. They were the same idea, the same size, the same color. They did not look related.
The dashboard icon was a thin stroked Lucide rocket. The Shopify icon was a chunky Heroicons solid. Next to each other they read as two different brands. I had been telling people RAXXO was one studio, one system, one feeling. The icons were calling me a liar.
That afternoon I started counting how many icon systems were actually live across the studio. The answer was five. The fix was one. This is the story of how I collapsed five into one, why Phosphor won, and the bundle math that turned a vanity migration into a measurable performance win.
The Five Icon Systems I Had Before Phosphor
Here is what was actually shipping across the 15 repos.
Heroicons was the original choice in three Next.js apps. I had used both @heroicons/react/24/solid and @heroicons/react/24/outline because some screens needed solid and some needed outline. That meant two icon packs imported in the same app. Tree-shaking helps, but I had a habit of importing the whole module in a few places, and the worst app was pulling 84KB of icon code into the client bundle.
Lucide had crept into two newer Next.js apps because I liked the stroke weight. It tree-shook better, landing at 31KB for similar icon counts. The visual rhythm was fine inside those apps. The problem was that Lucide strokes and Heroicons solids look nothing alike.
Emoji as icons was the dirty secret of three vanilla HTML landing pages. A rocket emoji next to a CTA reads fine on macOS Safari. It reads like a different font on Windows Chrome. It reads like a small typographic accident on Android. I had told myself it was fine because the pages were temporary. Several of those pages had been live for a year.
Custom inline SVGs were scattered across four Shopify themes. Some were ripped from old Figma exports. Some I had drawn myself at 2 a.m. They had no consistent stroke width, no consistent corner radius, no consistent viewport size. A few were 24x24, a few were 32x32, one mystery icon was 20x20 with a 1.5px stroke that looked thinner than everything around it.
Font Awesome stragglers lived in two Shopify themes I had inherited from earlier work. Loading the full Font Awesome CSS to render four icons is the kind of decision you make once and then ignore for two years. I was ignoring it.
Five systems, no shared visual logic, four different stroke weights on any given Tuesday. That was the problem.
Why Phosphor Won: Six Weights, One Visual System
I evaluated three replacements. Heroicons was the incumbent and lost on weight count. Lucide was a strong contender, especially for tree-shaking and React ergonomics. Phosphor (https://phosphoricons.com) won on the dimension that mattered most for a studio that ships across Liquid, React, and vanilla HTML: weight options inside one family.
Phosphor ships six weights of the same icon set: Thin, Light, Regular, Bold, Fill, and Duotone. Every icon is drawn by the same hand at every weight. That means I can pick a heavier weight for one platform and a lighter weight for another and still have them read as one system.
That sounds like a small thing. It is the entire reason this works.
Dark themes eat thin strokes. On raxxo.shop, the background is #1f1f21 and the text is #F5F5F7. A 1.5px stroke icon at 20px size disappears into the background noise. A filled icon at the same size has presence. The Shopify theme needed Fill weight.
The React apps have lighter backgrounds in places, denser layouts, and more icons per screen. A Fill weight there would feel shouty. Regular weight, with its 1.5px stroke, sits cleanly in dense UI.
With Heroicons I could pick solid or outline, two options. With Lucide I had one weight and a stroke-width prop, which is not the same as a hand-drawn weight variant. With Phosphor I had six weights drawn by the same designer, and I only needed two of them.
The decision was made by Friday.
The Liquid vs React Split: Fill in Themes, Regular in Apps
The rule I locked in across the studio is simple. Liquid themes get Phosphor Fill. React and Next.js apps get Phosphor Regular. No surface mixes weights.
In Shopify themes I do not import a package. I drop the SVG path data inline. That keeps the theme dependency-free and lets the icon render before any JavaScript loads. Here is the convention I use in every Liquid snippet.
{% comment %} Phosphor Fill weight, inline SVG, no JS dependency {% endcomment %}
The ph-fill and ph-rocket-launch classes are mine, not Phosphor's. They give me a CSS hook for hover states and color overrides without touching the SVG attributes. I grab the raw path data from the Phosphor site and paste it once into a Liquid snippet. From then on the icon is just {% render 'icon-rocket-launch' %}.
In React apps I import from @phosphor-icons/react, which is the official package. It tree-shakes correctly when you import individual components. Here is the pattern I use everywhere.
import { RocketLaunch } from '@phosphor-icons/react';
export function CTA() {
return (
Launch project
);
}
The weight="regular" prop is explicit, even though Regular is the default. I want the convention visible in the code so the next person (or the next me) does not casually drop a Fill icon into a React app.
The Shopify (https://shopify.pxf.io/5k5rj9) theme repos and the Next.js app repos now share a vocabulary. A rocket means the same thing in both places. It just wears different clothes for the room.
This is the same logic I used when I built the 4-tier dark mode color system for these projects. Different surfaces, shared rules.
The Migration: 40 Icons, 15 Repos, One Afternoon
The studio uses about 40 unique icons across all 15 repos. I counted by grepping for icon imports across the workspace.
Step one was inventory. Ripgrep made this fast.
rg "from '@heroicons" -l
rg "from 'lucide-react'" -l
rg "fa-(rocket|cart|user)" -l
I dumped every match into a spreadsheet with the file path, the old icon name, and the intended Phosphor equivalent. Forty rows, sorted by repo. That spreadsheet became the migration plan.
Step two was the codemod for React apps. Heroicons and Lucide have different import paths and different component names, so a single regex was not enough. I wrote a small Node script that walked each repo, parsed import statements, and rewrote them. The Heroicons RocketLaunchIcon became Phosphor RocketLaunch. The Lucide Rocket became Phosphor RocketLaunch. Naming differences were handled by a hand-written map in the script. Sixty lines of code, including the map.
Step three was the Liquid themes. No codemod here. I copied 40 Phosphor Fill paths into a snippet folder, one file per icon, named like icon-rocket-launch.liquid. Then I did a find-and-replace across each theme: old inline SVG out, {% render 'icon-rocket-launch' %} in. This was the slowest part, maybe two hours, because I had to eyeball each old icon to figure out which Phosphor name matched it.
Step four was the emoji-as-icons cleanup on the vanilla HTML pages. I replaced each emoji with the same inline SVG pattern as the Liquid themes. Same paths, same class names. One copy-paste per icon.
By 6 p.m. the workspace was on one icon system. Fifteen repos, one weight per platform, zero mismatches between any two RAXXO tabs.
I committed each repo separately with a message that explained what changed and why. That commit log is now my reference if I forget which icons existed before. Future me will thank present me.
Bundle Math: 84KB to 18KB on the Worst Case
The headline number is real. The worst-case Next.js app was importing 84KB of icon code before the migration. Heroicons solid plus Heroicons outline, both pulled into client bundles, with a few lazy imports that defeated tree-shaking. Lighthouse was flagging it on slower connections.
After the migration, that same app imports 18KB of Phosphor icon code. Tree-shaken, individual component imports, no barrel files. That is a 78% reduction on the worst case.
The other Next.js apps were already on Lucide at 31KB. They dropped to 18KB. About a 42% reduction.
Average across all the JS-bundle-relevant repos, the cut is roughly 60%. That is the number I put in the title because it is the honest studio-wide figure.
The Shopify themes do not have a bundle cost in the same way. Inline SVG paths add a small amount of HTML weight per page, but no JS, no font file, no extra HTTP request. The Font Awesome removal alone saved 76KB of CSS from two themes. That weight is gone from the critical render path.
A few specifics on how to actually hit 18KB. Import each Phosphor icon as a named import from the root package, not from a deep path. The package's ESM build is set up so named imports tree-shake correctly. Do not import the whole module with a wildcard. Do not import from @phosphor-icons/react/dist/... directly. The clean path is the boring path.
For very heavy pages with rarely used icons, dynamic import works fine.
const Settings = dynamic(() =>
import('@phosphor-icons/react').then(m => m.Settings)
);
I use this on dashboard surfaces where a settings panel is mounted lazily. It saves another 0.4KB per icon kept out of the initial bundle. Small, but it adds up across a dense app. The same approach is described in my notes on Tailwind v4 tokens, where lazy imports keep the critical CSS lean.
Bottom Line
One icon family, two weights, fifteen repos. That is the whole system. A rocket icon in a Shopify checkout and a rocket icon in a Next.js dashboard now feel like siblings. They wear different weights because their environments are different. They come from the same hand because the studio is one studio.
The bundle savings were the side effect that justified the migration to the part of my brain that needs measurable wins. Cutting 84KB to 18KB on the worst app, dropping 60% on average, removing Font Awesome from two themes. Those numbers are real and they ship to every visitor.
The deeper win is the one I cannot put in a Lighthouse report. Visual consistency across surfaces is what makes a studio feel like a studio instead of a folder of side projects. Phosphor (https://phosphoricons.com) gave me one family with enough weights to handle every platform I touch. I picked two of the six weights and locked the rule in. The hard part was deciding. The migration was an afternoon.
Top comments (0)