Icon library decision: Lucide for components, FontAwesome for diagrams
Status: DECIDED — not migrating. · Type: Decision record (+ deferred plan) · Owner: Design System
Decision
The design system keeps Lucide (lucide-react) for component UI icons.
FontAwesome (inline fas:/fab:) is used only inside Mermaid diagrams — in the
docs site and the DS’s InteractiveMermaid component. We evaluated unifying
everything on FontAwesome and chose not to.
Why two libraries (and why that’s fine)
The deciding constraint is Mermaid: its inline icon syntax (fas:fa-user in
a node label — the look we standardized on) works only with FontAwesome. Every
other set (Lucide, Tabler, Material) is reachable in Mermaid only as an icon-node
(@{ shape: icon }), the tile-with-caption model we rejected. So diagrams are
locked to FontAwesome regardless.
That leaves only the DS’s component icons as a real choice — and Lucide wins:
- Lucide is a stronger component set (~1,600 clean outline icons vs FontAwesome Free’s ~1,400 heavier solid; FA’s real breadth is Pro-only).
- The two systems never overlap in authoring — component code uses
<Icon name="…">(Lucide); diagram code usesfas:fa-…(FontAwesome). Different files, different tasks. “Two libraries” costs a little bundle size and means a button’s glyph isn’t pixel-identical to a diagram node’s — which we decided does not matter. - Migrating ~95 icons across 18 files to a smaller, heavier set was real risk for no functional gain.
The only thing FontAwesome-everywhere would have bought is glyph-identity between a button and a diagram node; we judged that not worth a downgrade + migration.
If we ever reconsider
The detailed FontAwesome migration plan is preserved below (§2 onward) as a ready-to-execute appendix. It only becomes relevant if the priority ever flips to “one icon glyph set everywhere” — or if we move component icons to a bigger open-source set (Tabler, ~5,900, MIT) and accept Mermaid icon-nodes. Do not execute the plan below unless that decision is explicitly revisited.
Deferred appendix — FontAwesome migration plan (do NOT execute unless the decision above is reversed)
The remainder of this document is the migration mechanics, kept for reference only.
1. (Superseded) Original decision framing
FontAwesome Free becomes the single default icon set across the design system,
the docs, and Mermaid diagrams. Lucide (lucide-react) is removed.
Consequences
- Icon names change (
settings→gear,search→magnifying-glass, …). The<Icon name="…">public API stays; a name-map absorbs the change. - 4 icons have no clean Free equivalent and need a substitute decision (§6).
- Visual shift: FA glyphs look different from Lucide. A Storybook visual pass is required before merge.
- New dependencies:
@fortawesome/react-fontawesome,@fortawesome/free-solid-svg-icons,@fortawesome/free-brands-svg-icons. (@fortawesome/fontawesome-free— the CSS/webfont — is already installed and stays, for Mermaid inline icons.)
2. Target architecture — make future swaps cheap
The goal beyond this migration: swapping icon libraries again should be a
one-file change, not a repo-wide edit. We get there by putting every
library-specific detail behind a single adapter and keeping the name-based
<Icon> API as the only thing consumers touch.
2.1 Library-agnostic types (new)
Today LucideIcon / LucideProps leak into consumers via src/lib/icon-pack.ts
and DesignSystemIconProps. Replace with library-neutral types:
// src/lib/icon-types.ts (NEW)export interface IconRenderProps { className?: string; size?: number | string; // maps to width/height or font-size "aria-label"?: string; "aria-hidden"?: boolean; title?: string;}export interface IconMeta { name: string; // canonical registry name (kebab-case) category: string; keywords: readonly string[];}export interface IconAdapter { has(name: string): boolean; render(name: string, props?: IconRenderProps): ReactElement | null; list(): readonly IconMeta[];}No consumer imports anything from lucide-react or @fortawesome/* ever again —
they import only Icon, IconRenderProps, IconName from the design system.
2.2 The FontAwesome adapter (new)
// src/lib/icon-adapters/fontawesome.ts (NEW)// Explicit icon map = tree-shaking. Only icons we register are bundled.import { faGear, faMagnifyingGlass, /* …only what we use… */ } from "@fortawesome/free-solid-svg-icons";import { faGithub } from "@fortawesome/free-brands-svg-icons";// name (kebab) -> FA icon definition, built from the §5 mapexport const fontAwesomeAdapter: IconAdapter = { has, render, list };render returns <FontAwesomeIcon icon={def} className={…} …/> (real SVG, sized
and coloured via props/CSS — better a11y and control than the <i> webfont form).
2.3 The registry delegates to the active adapter
src/lib/icons.tsx stops reflecting over lucide-react and instead delegates to
the active adapter (fontAwesomeAdapter). It keeps its public API
(isIconName, getIconComponent→renderIcon, createIconNode,
resolveIconChoice, iconDefinitions, iconNames) so the two dynamic call sites
(form-widget/blocks/registry.tsx, icon-library.tsx) keep working unchanged.
2.4 Tree-shaking note
Lucide’s registry auto-imported the entire lucide-react namespace (~1,500
icons, not tree-shaken). FontAwesome via explicit per-icon imports is
tree-shaken — but that means the registry only knows the icons we register. Two
consequences, both intended:
- The searchable gallery (
icon-library.tsx) shows the curated registered set, not “all 30k FA icons.” Register the full referenced set (§5) plus any icons the gallery should advertise. - Bundle size drops (no more whole-namespace import).
2.5 Future: multiple libraries at once (documented, not built now)
The IconAdapter interface is the seam. To support >1 library later:
- Register several adapters keyed by prefix (
fa:gear,lucide:gear). renderIconparses the prefix and routes to the adapter; unprefixed = default.- Each adapter tree-shakes independently.
We are not building this now (§1 says remove Lucide). We are only building the seam so it is a later drop-in. See §8 for the repeatable swap procedure.
2.6 Delivery mechanism — the one open decision
| Option | For | Against | Verdict |
|---|---|---|---|
@fortawesome/react-fontawesome (SVG) | Real SVG, prop-driven size/colour, tree-shaken, matches Lucide’s React model, best a11y | New deps; explicit icon imports | Recommended for the <Icon> component |
CSS/webfont <i className="fa-solid fa-gear"> | Zero new deps (font already loaded for Mermaid), simplest | Whole font loaded (not tree-shaken), <i> glyph not SVG, weaker sizing/colour control | Keep only for Mermaid inline, not the component |
Proceed with react-fontawesome SVG for components, CSS/webfont for Mermaid
inline (already in place). If the team prefers zero-new-deps, the <i> approach
is the fallback — but the plan below assumes the SVG approach.
3. Current state (from inventory)
- Registry layer (4 files):
src/lib/icon-pack.ts(barrel of ~108 Lucide re-exports +LucideIcon/LucidePropstypes),src/lib/icons.tsx(registry engine, namespace import + alias map),src/components/ui/icon.tsx(<Icon name>),src/components/ui/icon-library.tsx(searchable gallery). Plussrc/lib/storybook-icon-controls.ts(curated control names). - 16 non-registry consumer files import
lucide-reactdirectly (9 components, 7 stories). <Icon name>is only called dynamically (2 sites); no static string literals. The names that must resolve come fromcreateIconNodeliterals, the curated control list, and the kebab-caseICON_NAME_ALIASES.- ~95 distinct Lucide icons referenced in source; ~62 exact FA matches, ~24 close, 4 need substitutes.
- Packages:
lucide-react@^0.511.0present; FA React packages absent;@fortawesome/fontawesome-free@^7.3.0present (webfont, for Mermaid).
4. Workstreams (parallelizable)
WS-0 is the foundation and blocks everything else. WS-1…WS-6 run in parallel once WS-0 merges. Each is sized for one agent.
WS-0 — Foundation (BLOCKING; do first, alone)
Files: new src/lib/icon-types.ts, new src/lib/icon-adapters/fontawesome.ts,
rewrite src/lib/icons.tsx, rewrite src/lib/icon-pack.ts (drop Lucide types →
re-export neutral types), update src/components/ui/icon.tsx (IconProps extends IconRenderProps, not LucideProps). Add deps: @fortawesome/react-fontawesome,
@fortawesome/free-solid-svg-icons, @fortawesome/free-brands-svg-icons.
Do:
- Add the three FA React packages.
- Create
icon-types.ts(§2.1). - Create
icon-adapters/fontawesome.tswith the explicit icon map built from the §5 table (import each FA icon, map kebab-name → def, implementhas/render/list). - Rewrite
icons.tsxto delegate tofontAwesomeAdapter; preserve every exported name/signature listed in §3 so downstream compiles. KeepICON_NAME_ALIASESbut re-point alias targets to kebab FA names. - Update
icon.tsxsoIconProps extends IconRenderPropsand it calls the registry’s render path. - Keep
icon-pack.tsas a thin barrel but re-export the neutral types and a deprecation note; components must stop importing icon components from it.
Acceptance: pnpm typecheck clean; <Icon name="gear" /> renders an FA gear;
isIconName("gear") true; the two dynamic call sites compile. No lucide-react
import remains in the registry layer.
WS-1 — App components batch A (parallel)
Files (4):
src/components/ui/interactive-mermaid.tsx—CheckCircle2, ChevronDown, Code2, Copy, Download, FileCode2, Fullscreen, ImageDown, ListRestart, Maximize2, Minimize2, TerminalSquare, ZoomIn, ZoomOutsrc/components/ui/docs-export-menu.tsx—Download, FileText, Link, Printersrc/components/ui/docs-auto-toc.tsx—ChevronRightsrc/components/ui/step-list.tsx—Check
Do: replace each direct lucide-react import with <Icon name="…" /> using the
§5 kebab names (e.g. Copy→<Icon name="copy" />, ZoomIn→<Icon name="magnifying-glass-plus" />,
Settings→<Icon name="gear" />). Preserve every existing size/className/aria prop.
Acceptance: no lucide-react import in these files; pnpm typecheck + pnpm build
clean; icons visually present in Storybook for each component.
WS-2 — App components batch B (parallel)
Files (5):
src/components/ui/ato-status-badge.tsx—AlertTriangle, Ban, CheckCircle2, CircleDashed, HelpCirclesrc/components/ui/cards/testimonial-card.tsx—Starsrc/components/ui/placeholder.tsx—ImageIcon(=Image)src/components/patterns/myfreelawyer/components/legal-document.tsx—Box, Check, Download, FileText, Printer, Trash2src/components/patterns/myfreelawyer/components/legal-exhibit-appendix.tsx—Mail
Do: same as WS-1. Note CircleDashed→circle-notch (close, §6 flag),
Trash2→trash, Mail→envelope, ImageIcon→image.
Acceptance: identical to WS-1.
WS-3 — Story files (parallel)
Files (7): KPI Summary Strip.stories.tsx, Metric Card.stories.tsx,
Choice Card.stories.tsx, Radio Group.stories.tsx, Number Ticker.stories.tsx,
Placeholder.stories.tsx, Bottom Nav.stories.tsx.
Do: replace live lucide-react JSX with <Icon name="…">. For icons that live
inside source.code doc-strings (Metric Card, Number Ticker, KPI Summary Strip,
Bottom Nav), update the sample text to the <Icon name> form too so the shown code
matches reality.
Acceptance: no lucide-react text remains; pnpm run adr:034:check + build clean;
stories render.
WS-4 — Gallery + controls (parallel)
Files: src/components/ui/icon-library.tsx, src/lib/storybook-icon-controls.ts.
Do: icon-library.tsx needs no logic change (it reads iconDefinitions) — but
confirm it renders the curated FA set and the copy-snippet still emits
<Icon name="…" />. In storybook-icon-controls.ts, replace the PascalCase Lucide
control names with the kebab FA names from §5 so the Storybook icon-picker controls
list valid names.
Acceptance: gallery renders FA icons; icon-picker controls resolve; build clean.
WS-5 — Cleanup + gate (AFTER WS-1…WS-4)
Do:
pnpm remove lucide-react.- Delete any dead Lucide-specific code left in the registry/barrel.
- Repo-wide gate:
grep -r "lucide-react" srcreturns nothing. - Full
pnpm lint && pnpm typecheck && pnpm build && pnpm test.
Acceptance: zero lucide-react references; all gates green.
WS-6 — Docs + skill mirror (parallel with 1–4)
Do: update any DS docs that reference Lucide as the icon set; run pnpm run ai:sync
if skill mirrors changed. Update the DS’s icon-usage docs to show <Icon name> with
FA names.
5. Full Lucide → FontAwesome Free name map
Registry canonical names are kebab-case (the FA suffix). <Icon name="gear" />.
| Lucide (PascalCase) | <Icon name> (kebab) | FA Free token | Match |
|---|---|---|---|
| Activity | wave-square | fa-solid fa-wave-square | close |
| AlertCircle / CircleAlert | circle-exclamation | fa-solid fa-circle-exclamation | exact |
| AlertTriangle / TriangleAlert | triangle-exclamation | fa-solid fa-triangle-exclamation | exact |
| AlignLeft | align-left | fa-solid fa-align-left | exact |
| ArrowDownRight | arrow-trend-down | fa-solid fa-arrow-trend-down | close |
| ArrowLeft | arrow-left | fa-solid fa-arrow-left | exact |
| ArrowLeftRight | right-left | fa-solid fa-right-left | exact |
| ArrowRight | arrow-right | fa-solid fa-arrow-right | exact |
| ArrowUpRight | arrow-trend-up | fa-solid fa-arrow-trend-up | close |
| Ban | ban | fa-solid fa-ban | exact |
| Banknote | money-bill | fa-solid fa-money-bill | exact |
| BarChart2 | chart-column | fa-solid fa-chart-column | exact |
| Box | box | fa-solid fa-box | exact |
| Boxes | boxes-stacked | fa-solid fa-boxes-stacked | exact |
| Camera | camera | fa-solid fa-camera | exact |
| Check | check | fa-solid fa-check | exact |
| CheckCircle2 / check-circle | circle-check | fa-solid fa-circle-check | exact |
| ChevronDown | chevron-down | fa-solid fa-chevron-down | exact |
| ChevronLeft | chevron-left | fa-solid fa-chevron-left | exact |
| ChevronRight | chevron-right | fa-solid fa-chevron-right | exact |
| Circle | circle | fa-regular fa-circle | exact |
| CircleDashed | circle-notch | fa-solid fa-circle-notch | close ⚠ |
| ClipboardList | clipboard-list | fa-solid fa-clipboard-list | exact |
| Code2 | code | fa-solid fa-code | exact |
| Copy | copy | fa-solid fa-copy | exact |
| Cpu | microchip | fa-solid fa-microchip | exact |
| Database | database | fa-solid fa-database | exact |
| Download | download | fa-solid fa-download | exact |
| ExternalLink | arrow-up-right-from-square | fa-solid fa-arrow-up-right-from-square | exact |
| FileCode2 | file-code | fa-solid fa-file-code | exact |
| FileJson | file-code | fa-solid fa-file-code | close |
| FileText | file-lines | fa-solid fa-file-lines | exact |
| Folder | folder | fa-solid fa-folder | exact |
| FolderOpen | folder-open | fa-solid fa-folder-open | exact |
| Fullscreen / Maximize2 | expand | fa-solid fa-expand | close |
| Gauge | gauge-high | fa-solid fa-gauge-high | exact |
| GitBranchPlus | code-branch | fa-solid fa-code-branch | close |
| Github | github | fa-brands fa-github | exact |
| Globe | globe | fa-solid fa-globe | exact |
| HardDrive | hard-drive | fa-solid fa-hard-drive | exact |
| HelpCircle / help-circle / CircleHelp | circle-question | fa-solid fa-circle-question | exact |
| Home | house | fa-solid fa-house | exact |
| Image / ImageIcon | image | fa-regular fa-image | exact |
| ImageDown | image | fa-regular fa-image | close ⚠ |
| Info | circle-info | fa-solid fa-circle-info | exact |
| LayoutGrid | table-cells-large | fa-solid fa-table-cells-large | close ⚠ |
| LayoutTemplate | table-columns | fa-solid fa-table-columns | close ⚠ |
| Link | link | fa-solid fa-link | exact |
| List | list | fa-solid fa-list | exact |
| ListRestart | list-check | fa-solid fa-list-check | close |
envelope | fa-solid fa-envelope | exact | |
| MapPin | location-dot | fa-solid fa-location-dot | exact |
| Minimize2 | compress | fa-solid fa-compress | close |
| MinusCircle | circle-minus | fa-solid fa-circle-minus | exact |
| Monitor / MonitorSmartphone | display | fa-solid fa-display | close |
| MoreHorizontal | ellipsis | fa-solid fa-ellipsis | exact |
| PanelLeft / PanelsTopLeft | table-columns | fa-solid fa-table-columns | close ⚠ |
| Paperclip | paperclip | fa-solid fa-paperclip | exact |
| PencilLine | pen | fa-solid fa-pen | close |
| Plus | plus | fa-solid fa-plus | exact |
| Printer | print | fa-solid fa-print | exact |
| Radar | satellite-dish | fa-solid fa-satellite-dish | close ⚠ |
| Rocket | rocket | fa-solid fa-rocket | exact |
| RotateCcw | rotate-left | fa-solid fa-rotate-left | exact |
| Rows3 | bars | fa-solid fa-bars | close ⚠ |
| ScrollText | scroll | fa-solid fa-scroll | exact |
| Search | magnifying-glass | fa-solid fa-magnifying-glass | exact |
| Server | server | fa-solid fa-server | exact |
| Settings / Settings2 | gear | fa-solid fa-gear | exact |
| Shapes | shapes | fa-solid fa-shapes | exact |
| Shield | shield-halved | fa-solid fa-shield-halved | exact |
| Sparkles | wand-magic-sparkles | fa-solid fa-wand-magic-sparkles | close ⚠ |
| Square | square | fa-regular fa-square | exact |
| Star | star | fa-solid fa-star | exact |
| Table2 | table | fa-solid fa-table | exact |
| TerminalSquare | terminal | fa-solid fa-terminal | close |
| TrendingUp | arrow-trend-up | fa-solid fa-arrow-trend-up | exact |
| Type | font | fa-solid fa-font | close |
| Upload | upload | fa-solid fa-upload | exact |
| Users | users | fa-solid fa-users | exact |
| Wallet | wallet | fa-solid fa-wallet | exact |
| X | xmark | fa-solid fa-xmark | exact |
| ZoomIn | magnifying-glass-plus | fa-solid fa-magnifying-glass-plus | exact |
| ZoomOut | magnifying-glass-minus | fa-solid fa-magnifying-glass-minus | exact |
Alias map (ICON_NAME_ALIASES) — re-point to FA kebab names
| Alias | Old Lucide target | New <Icon name> |
|---|---|---|
| activities | Volleyball | volleyball |
| document | FileText | file-lines |
| expert | Stethoscope | stethoscope |
| medical | HeartPulse | heart-pulse |
| message | MessageSquare | comment |
| schedule | CalendarRange | calendar-days |
| talk | MessageSquareQuote | comment-dots |
| therapy | Brain | brain |
| travel | Plane | plane |
| check-circle | CheckCircle2 | circle-check |
| help-circle | CircleHelp | circle-question |
| alert-triangle | TriangleAlert | triangle-exclamation |
6. Substitution decisions (no exact Free equivalent)
These need a human/owner sign-off — the FA Free glyph diverges from the Lucide original. Defaults chosen above in bold; change here if undesired.
| Lucide | Chosen Free substitute | Note / alternative |
|---|---|---|
| CircleDashed | circle-notch | Spinner-like, not a true dashed ring. Only used in ato-status-badge. |
| Sparkles | wand-magic-sparkles | Carries a wand; standalone sparkles is Pro-only. |
| Radar | satellite-dish | Nearest metaphor; no radar glyph in Free. |
| Rows3 | bars | Generic; no “3 rows” glyph in Free. |
| LayoutGrid / LayoutTemplate / PanelLeft | table-cells-large / table-columns | Layout approximations. |
| ImageDown | image | Loses the “download” overlay; pair with a separate download if needed. |
| ChevronsUpDown / ChevronsDownUp | sort (or angles-up/angles-down) | Barrel-only; not used by any consumer today. |
| GripVertical | ellipsis-vertical | grip-vertical is Pro-only. Barrel-only. |
| GitCommitHorizontal | code-commit | Close; barrel-only. |
ChevronsUpDown, ChevronsDownUp, GripVertical, GitCommitHorizontal appear
only in the icon-pack.ts barrel, not in any live consumer — so they need a map
entry only if the barrel keeps advertising them.
7. Validation (every workstream)
Run and fix to green before handing off a workstream:
pnpm run lintpnpm run typecheckpnpm run buildpnpm run test # WS-0 and WS-5 especiallypnpm run adr:034:check # story files (WS-3)Final gate (WS-5): grep -rn "lucide-react" src returns nothing.
Visual pass (required before merge): run Storybook (pnpm storybook, :6006) and
eyeball the components touched in WS-1/2/3 and the icon gallery (WS-4) — FA glyphs
differ from Lucide, so confirm nothing looks broken or wrong-metaphor (especially the
§6 substitutes).
8. Runbook — swapping the icon library again (future)
Because §2’s adapter is the only library-specific code, a future swap is:
- Add the new library’s package(s).
- Write a new
IconAdapterinsrc/lib/icon-adapters/<lib>.ts(implementhas/render/list) with an explicit, tree-shaken icon map. - Point
icons.tsxat the new adapter (one line), or register it under a prefix for multi-library mode (§2.5). - Update the concept→name map +
ICON_NAME_ALIASESif names differ. - Run the §7 gates + the visual pass.
- Remove the old library.
No consumer files change — they only ever use <Icon name="…">. That is the whole
point of the adapter seam.
9. Suggested execution order
- WS-0 (foundation) — one agent, merges first.
- WS-1, WS-2, WS-3, WS-4, WS-6 — up to five agents in parallel off WS-0.
- WS-5 (cleanup + gate) — one agent, last, after 1–4 land.
- Visual pass + merge.