Architect
Practices
ReferenceNormative

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.

FamilySizeNote
Phosphor~1 500 × 6 weightsthin → bold, fill, duotone
Lucide~1 600one weight; ships with shadcn/ui
Tabler~5 800the largest stroke set
Iconoir~1 600
Heroicons~300small, very consistent; changes optical weight
Material Symbols~3 300variable 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 runtime

Accessibility

  • Decorative icon beside a label → aria-hidden.
  • Icon-only control → aria-label and title, 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:

LeavingArriving
ChevronDown / Right / UpCaretDown / Right / Up
SettingsGear
HomeHouse
SearchMagnifyingGlass
MoreVertical / EllipsisDotsThreeVertical / DotsThree
ListTreeTreeStructure
LogOutSignOut
TriangleAlert / OctagonXWarning / WarningOctagon
Loader2Spinner
PanelLeftClose / OpenSidebarSimple — one glyph, both directions
FolderCheckno 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.

IconIllustration
Jobname a controlcarry the product's identity
Size16–24px64–120px
Sourceone library, whole productdrawn for this product
Grammaruniform stroke and gridone ink line, allowed to be irregular
Wherebuttons, rows, navigation, fieldshero 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.

On this page