@phosphor-icons/react is a React icon library with 9,000+ icons, each available in six visual weights: thin, light, regular, bold, fill, and duotone. It supports tree-shaking, React Server Components, and custom icon composition through a public IconBase API.
Why I starred it
Most icon libraries give you one or two variants per icon and call it a day. Phosphor ships six — including duotone, which layers a semi-transparent fill behind the stroked path for a subtle depth effect you can actually use in a real UI. That's not just a design decision. It has architectural implications that are worth looking at.
The other thing that caught my eye: the CSR/SSR split. Plenty of libraries claim SSR support but just mean "it doesn't crash". Phosphor splits the problem cleanly at the component level, which I wanted to understand.
How it works
The repo lives at src/, with three subdirectories: lib/, csr/, and defs/. The split matters.
Every icon is backed by a Map<IconWeight, ReactElement> defined in src/defs/. Opening src/defs/Heart.tsx shows all six variants as inline JSX:
export default new Map<IconWeight, ReactElement>([
["bold", <><path d="M178,36c-20.09,0..." /></>],
["duotone", <>
<path d="M232,102c0,66..." opacity="0.2" />
<path d="M178,40c-20.65,0..." />
</>],
["fill", <><path d="M240,102c0,70..." /></>],
// thin, light, regular...
]);
The actual component in src/csr/Heart.tsx is two lines:
const I: Icon = React.forwardRef((props, ref) => (
<IconBase ref={ref} {...props} weights={weights} />
));
All the rendering logic lives in src/lib/IconBase.tsx. It reads from IconContext for defaults, then merges with per-instance props:
const IconBase = React.forwardRef<SVGSVGElement, IconBaseProps>((props, ref) => {
const { color, size, weight, mirrored, children, weights, ...restProps } = props;
const {
color: contextColor = "currentColor",
size: contextSize,
weight: contextWeight = "regular",
mirrored: contextMirrored = false,
...restContext
} = React.useContext(IconContext);
return (
<svg
ref={ref}
width={size ?? contextSize}
height={size ?? contextSize}
fill={color ?? contextColor}
viewBox="0 0 256 256"
transform={mirrored || contextMirrored ? "scale(-1, 1)" : undefined}
{...restContext}
{...restProps}
>
{!!alt && <title>{alt}</title>}
{children}
{weights.get(weight ?? contextWeight)}
</svg>
);
});
That weights.get(weight ?? contextWeight) is the whole weight system — a Map lookup. The defs are pre-parsed JSX, not strings, so there's no runtime parsing. The SVG paths are just pulled and dropped into the output element.
The SSR variant in src/lib/SSRBase.tsx is almost identical, but drops the useContext call entirely. It uses hardcoded defaults instead. That's the whole difference — no Context API dependency, no client boundary requirement. Clean.
The src/lib/context.ts defaults:
export const IconContext = createContext<IconProps>({
color: "currentColor",
size: "1em",
weight: "regular",
mirrored: false,
});
Zero runtime dependencies. The package.json shows dependencies: {} — everything is a peer dep or dev dep. The runtime footprint is: React + your icon paths.
Test coverage in test/index.test.tsx is methodical: it renders every icon in every weight and checks the result is truthy. ~54,000 test cases (9,000 icons × 6 weights). Not behavior testing, but it catches regressions in the generation pipeline.
Using it
Standard install with tree-shaking:
import { HeartIcon, BellIcon } from "@phosphor-icons/react";
<HeartIcon weight="duotone" size={32} color="crimson" />
For Next.js App Router, add optimizePackageImports to avoid the bundler eagerly compiling all 9,000 modules:
// next.config.ts
export default {
experimental: {
optimizePackageImports: ["@phosphor-icons/react"],
},
};
Or import directly from the submodule path:
import { BellSimpleIcon } from "@phosphor-icons/react/dist/csr/BellSimple";
For Server Components, use the SSR submodule:
import { FishIcon } from "@phosphor-icons/react/ssr";
The composability API is worth knowing about. Icon components accept arbitrary SVG children placed below the icon paths, relative to the 256×256 viewBox:
<CubeIcon color="darkorchid" weight="duotone">
<animateTransform attributeName="transform" type="rotate"
dur="5s" from="0 0 0" to="360 0 0" repeatCount="indefinite" />
</CubeIcon>
Custom icons use the same IconBase directly:
import { forwardRef } from "react";
import { Icon, IconBase, IconWeight } from "@phosphor-icons/react";
const weights = new Map<IconWeight, ReactElement>([
["regular", <path d="..." />],
["bold", <path d="..." />],
// ...
]);
const MyIcon: Icon = forwardRef((props, ref) => (
<IconBase ref={ref} {...props} weights={weights} />
));
Rough edges
The dev-mode bundle size warning is real. If your bundler doesn't support optimizePackageImports or you forget to configure it, you'll pay for all 9,000+ modules at startup. The README covers this, but it's easy to miss until your dev server is slow.
The duotone weight requires you to manually set the 20% opacity on the background path when building custom icons — nothing enforces it. Check the defs files for examples if you're building custom duotone icons.
There's also a legacy package split: phosphor-react (the old name) still exists under a different npm package and won't receive new icons. The README flags this prominently, but if you're inheriting a codebase it's worth checking which one you have.
Bottom line
If you need a large, consistent icon set with multiple visual weights and proper SSR support, Phosphor is a solid choice. The architecture is honest — no magic, just a Map and a clean component boundary between CSR and SSR.