Phosphor Icons for React: Six weights, one clean component

August 29, 2023

|repo-review

by Florian Narr

Phosphor Icons for React: Six weights, one clean component

@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.

phosphor-icons/react on GitHub
phosphor-icons/react