Iconography
One icon family sized by its container, imported so it survives server rendering — and why illustrations are a second register that must not be merged into it.
An icon set is a language. Two of them in one product is an accent nobody placed on purpose.
Pick one family, write it down
Any family on a 24px stroke grid works. What matters is that the choice is recorded once, per product, so the next component does not arrive in a different dialect.
| Family | Size | Note |
|---|---|---|
| Phosphor | ~1 500 × 6 weights | thin → bold, fill, duotone |
| Lucide | ~1 600 | one weight; ships with shadcn/ui |
| Tabler | ~5 800 | the largest stroke set |
| Iconoir | ~1 600 | |
| Heroicons | ~300 | small, very consistent; changes optical weight |
| Material Symbols | ~3 300 | variable axes; changes the metaphor set too |
The first four share a grammar and swap without touching call sites. The last two change how every screen reads.
Iconify is a different category — an aggregator over 150+ collections. Reach for it when a product genuinely needs marks from several families; not to avoid choosing.
Never pass a size
Sizes belong to the container, not the call site. One rule per container, and every icon inside obeys it:
// button — every bare icon inside is 16px
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
// empty state — the tile icon is 20px, the steps strip 16px
"[&_svg]:size-5"
"[&_svg]:size-4"<Button><PlusIcon aria-hidden />New</Button> {/* no size prop anywhere */}The :not([class*='size-']) guard lets one icon opt out by carrying its own size
class, without the container's rule fighting it.
This is what turns a family migration into a mechanical rename rather than an audit:
libraries disagree on the default size — 24px here, 1em there — and the default
never surfaces because nothing relies on it.
Import so it survives server rendering
Some families wire every icon to a React context so an ancestor can set defaults. Unless the product actually uses that context, prefer the context-free entry point: no subscription, no client boundary, correct under SSR.
import { HouseIcon, BellIcon } from "@phosphor-icons/react/ssr"
import type { Icon } from "@phosphor-icons/react" // types cost nothing at runtimeAccessibility
- Decorative icon beside a label →
aria-hidden. - Icon-only control →
aria-labelandtitle, both saying what the click does, never what state you are in: "Collapse the rail", not "Collapsed". - An icon never carries a status alone. See registers.
Migrating between families
Budget for the metaphors, not the names. Going from Lucide to Phosphor, the ones that surprise people:
| Leaving | Arriving |
|---|---|
ChevronDown / Right / Up | CaretDown / Right / Up |
Settings | Gear |
Home | House |
Search | MagnifyingGlass |
MoreVertical / Ellipsis | DotsThreeVertical / DotsThree |
ListTree | TreeStructure |
LogOut | SignOut |
TriangleAlert / OctagonX | Warning / WarningOctagon |
Loader2 | Spinner |
PanelLeftClose / Open | SidebarSimple — one glyph, both directions |
FolderCheck | no counterpart; substitute SealCheck |
Method that works: build the map, rewrite every import and identifier in one scripted pass per file, then let the type checker find the rest. Do the whole product at once — a half-migrated codebase is two icon languages.
Component generators ship with an icon family baked in. Every component added after the migration arrives in the old language and needs converting on arrival. Record that in the project's own conventions; no reviewer remembers it.
The expand/collapse glyph
The panel itself — a rectangle with a left column — and the same glyph in both directions. An arrow that flips has to be decoded: does it point where the panel is going, or where it is now? The direction is carried by the label.
Illustrations are not icons
A second register, with a different job. Merging them costs the brand and gains nothing.
| Icon | Illustration | |
|---|---|---|
| Job | name a control | carry the product's identity |
| Size | 16–24px | 64–120px |
| Source | one library, whole product | drawn for this product |
| Grammar | uniform stroke and grid | one ink line, allowed to be irregular |
| Where | buttons, rows, navigation, fields | hero cards, empty states, confirmations |
An illustration earns its place or it goes. Reserve them for screens of consequence. Under every form field they become footer ornament — they fall below the fold while the user types and surface only after they have scrolled past the buttons.
Subjects that could not come from a stock library. That is the entire point. A product whose illustrations are interchangeable with any other product's has spent the budget and bought nothing.
One stroke, no fill, currentColor. The surface chooses the ink, so the drawing
survives being placed anywhere.
const stroke = {
fill: "none",
stroke: "currentColor",
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
strokeWidth: 1.8,
}Match the optical weight to the icon set deliberately. At 80px a 1.8 stroke on a 64 viewBox lands near 2.2px — close to a thin-weight library icon at the same size. Close is what you want: one hand, not a sticker on a UI.
Do not replace an illustration set with library glyphs, even when the library covers most of the subjects. The moment one subject has no counterpart you have three stock glyphs beside one drawing, and the honest fix is then to drop the drawing — at which point the tinted bands are decorating stock icons. An illustration that genuinely does not read is redrawn, not swapped.