Icons
Phosphor is the system's icon set. Regular weight carries structure, fill carries state, and the glyphs built into components are Phosphor's own paths inlined rather than imported.
The set
Phosphor is the official icon set. Around 9,000 glyphs, six weights, drawn on a 256 grid — and the vocabulary the design studies this system was built from already speak.
npm install @phosphor-icons/reactImport from the SSR entry so glyphs render on the server and stay out of the client bundle:
import { CheckCircle, WarningCircle } from "@phosphor-icons/react/dist/ssr";
<CheckCircle weight="fill" />Both entry points are barrels over the full set, so next.config.mjs lists the
package under optimizePackageImports. Without it, one glyph pulls all nine
thousand through the dev compiler on every change.
Two weights, two jobs
The other four weights — thin, light, bold, duotone — are not part of the system. Reach for one only with a reason you can write down.
<CaretDown className="size-4" />
<Info weight="fill" className="size-4 text-info" />Components inline their glyphs
The icons built into components — the accordion caret, the checkbox tick, the banner's status marks — are Phosphor's path data inlined, not imports.
Two reasons, and both are load-bearing:
- A registry component has to install clean.
shadcn add @sc1m/accordioncopies one file. If that file imported an icon package, every consumer would inherit a dependency to get a caret. - The React icons are client components. They read weight from context, so importing one into Button, Banner or Badge would pull a server-safe component into the client bundle for a static shape.
The cost of inlining is drift — a hand-nudged path quietly stops being Phosphor. So every inline glyph carries a provenance marker:
{/* phosphor: caret-down regular */}
<svg aria-hidden viewBox="0 0 256 256" fill="currentColor" className="size-4">
<path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80…" />
</svg>scripts/check-icon-parity.mjs reads every marker and compares the path against
@phosphor-icons/core, byte for byte. It fails on a single changed digit, which
is what makes inlining safe rather than a slow leak away from the set.
Rules
- Always
currentColor. A glyph inherits its colour from the text or token around it. No glyph names a hue. - Always
aria-hiddenunless the icon is the only content, in which case it needs a label. Decorative by default; the text carries the meaning. - Never icon-only status. A tint plus a glyph with no words fails in greyscale and fails for anyone who cannot separate the hues.
- Size with
size-*, neverwidth. The 256 viewBox scales cleanly, and utility sizing keeps glyphs on the spacing scale.
Icon slots
Components that accept a glyph expose it as a prop rather than taking it from children, so the slot can fix the box and stop a 24px source icon from blowing out a 24px chip.
<Badge tone="success" icon={<CheckCircle weight="fill" />}>Paid</Badge>The banner is the exception: its glyph is chosen by tone, because a warning
banner with a smiling face would be a bug, not a choice.
Design tokens
Glyphs take colour from whatever is around them, so they read the same tokens the text does.