Skip to content

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 uses fas: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 (settingsgear, searchmagnifying-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 map
export 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, getIconComponentrenderIcon, 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).
  • renderIcon parses 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

OptionForAgainstVerdict
@fortawesome/react-fontawesome (SVG)Real SVG, prop-driven size/colour, tree-shaken, matches Lucide’s React model, best a11yNew deps; explicit icon importsRecommended for the <Icon> component
CSS/webfont <i className="fa-solid fa-gear">Zero new deps (font already loaded for Mermaid), simplestWhole font loaded (not tree-shaken), <i> glyph not SVG, weaker sizing/colour controlKeep 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/LucideProps types), 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). Plus src/lib/storybook-icon-controls.ts (curated control names).
  • 16 non-registry consumer files import lucide-react directly (9 components, 7 stories).
  • <Icon name> is only called dynamically (2 sites); no static string literals. The names that must resolve come from createIconNode literals, the curated control list, and the kebab-case ICON_NAME_ALIASES.
  • ~95 distinct Lucide icons referenced in source; ~62 exact FA matches, ~24 close, 4 need substitutes.
  • Packages: lucide-react@^0.511.0 present; FA React packages absent; @fortawesome/fontawesome-free@^7.3.0 present (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:

  1. Add the three FA React packages.
  2. Create icon-types.ts (§2.1).
  3. Create icon-adapters/fontawesome.ts with the explicit icon map built from the §5 table (import each FA icon, map kebab-name → def, implement has/render/list).
  4. Rewrite icons.tsx to delegate to fontAwesomeAdapter; preserve every exported name/signature listed in §3 so downstream compiles. Keep ICON_NAME_ALIASES but re-point alias targets to kebab FA names.
  5. Update icon.tsx so IconProps extends IconRenderProps and it calls the registry’s render path.
  6. Keep icon-pack.ts as 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.tsxCheckCircle2, ChevronDown, Code2, Copy, Download, FileCode2, Fullscreen, ImageDown, ListRestart, Maximize2, Minimize2, TerminalSquare, ZoomIn, ZoomOut
  • src/components/ui/docs-export-menu.tsxDownload, FileText, Link, Printer
  • src/components/ui/docs-auto-toc.tsxChevronRight
  • src/components/ui/step-list.tsxCheck

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.tsxAlertTriangle, Ban, CheckCircle2, CircleDashed, HelpCircle
  • src/components/ui/cards/testimonial-card.tsxStar
  • src/components/ui/placeholder.tsxImageIcon (=Image)
  • src/components/patterns/myfreelawyer/components/legal-document.tsxBox, Check, Download, FileText, Printer, Trash2
  • src/components/patterns/myfreelawyer/components/legal-exhibit-appendix.tsxMail

Do: same as WS-1. Note CircleDashedcircle-notch (close, §6 flag), Trash2trash, Mailenvelope, ImageIconimage.

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.

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:

  1. pnpm remove lucide-react.
  2. Delete any dead Lucide-specific code left in the registry/barrel.
  3. Repo-wide gate: grep -r "lucide-react" src returns nothing.
  4. 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 tokenMatch
Activitywave-squarefa-solid fa-wave-squareclose
AlertCircle / CircleAlertcircle-exclamationfa-solid fa-circle-exclamationexact
AlertTriangle / TriangleAlerttriangle-exclamationfa-solid fa-triangle-exclamationexact
AlignLeftalign-leftfa-solid fa-align-leftexact
ArrowDownRightarrow-trend-downfa-solid fa-arrow-trend-downclose
ArrowLeftarrow-leftfa-solid fa-arrow-leftexact
ArrowLeftRightright-leftfa-solid fa-right-leftexact
ArrowRightarrow-rightfa-solid fa-arrow-rightexact
ArrowUpRightarrow-trend-upfa-solid fa-arrow-trend-upclose
Banbanfa-solid fa-banexact
Banknotemoney-billfa-solid fa-money-billexact
BarChart2chart-columnfa-solid fa-chart-columnexact
Boxboxfa-solid fa-boxexact
Boxesboxes-stackedfa-solid fa-boxes-stackedexact
Cameracamerafa-solid fa-cameraexact
Checkcheckfa-solid fa-checkexact
CheckCircle2 / check-circlecircle-checkfa-solid fa-circle-checkexact
ChevronDownchevron-downfa-solid fa-chevron-downexact
ChevronLeftchevron-leftfa-solid fa-chevron-leftexact
ChevronRightchevron-rightfa-solid fa-chevron-rightexact
Circlecirclefa-regular fa-circleexact
CircleDashedcircle-notchfa-solid fa-circle-notchclose ⚠
ClipboardListclipboard-listfa-solid fa-clipboard-listexact
Code2codefa-solid fa-codeexact
Copycopyfa-solid fa-copyexact
Cpumicrochipfa-solid fa-microchipexact
Databasedatabasefa-solid fa-databaseexact
Downloaddownloadfa-solid fa-downloadexact
ExternalLinkarrow-up-right-from-squarefa-solid fa-arrow-up-right-from-squareexact
FileCode2file-codefa-solid fa-file-codeexact
FileJsonfile-codefa-solid fa-file-codeclose
FileTextfile-linesfa-solid fa-file-linesexact
Folderfolderfa-solid fa-folderexact
FolderOpenfolder-openfa-solid fa-folder-openexact
Fullscreen / Maximize2expandfa-solid fa-expandclose
Gaugegauge-highfa-solid fa-gauge-highexact
GitBranchPluscode-branchfa-solid fa-code-branchclose
Githubgithubfa-brands fa-githubexact
Globeglobefa-solid fa-globeexact
HardDrivehard-drivefa-solid fa-hard-driveexact
HelpCircle / help-circle / CircleHelpcircle-questionfa-solid fa-circle-questionexact
Homehousefa-solid fa-houseexact
Image / ImageIconimagefa-regular fa-imageexact
ImageDownimagefa-regular fa-imageclose ⚠
Infocircle-infofa-solid fa-circle-infoexact
LayoutGridtable-cells-largefa-solid fa-table-cells-largeclose ⚠
LayoutTemplatetable-columnsfa-solid fa-table-columnsclose ⚠
Linklinkfa-solid fa-linkexact
Listlistfa-solid fa-listexact
ListRestartlist-checkfa-solid fa-list-checkclose
Mailenvelopefa-solid fa-envelopeexact
MapPinlocation-dotfa-solid fa-location-dotexact
Minimize2compressfa-solid fa-compressclose
MinusCirclecircle-minusfa-solid fa-circle-minusexact
Monitor / MonitorSmartphonedisplayfa-solid fa-displayclose
MoreHorizontalellipsisfa-solid fa-ellipsisexact
PanelLeft / PanelsTopLefttable-columnsfa-solid fa-table-columnsclose ⚠
Paperclippaperclipfa-solid fa-paperclipexact
PencilLinepenfa-solid fa-penclose
Plusplusfa-solid fa-plusexact
Printerprintfa-solid fa-printexact
Radarsatellite-dishfa-solid fa-satellite-dishclose ⚠
Rocketrocketfa-solid fa-rocketexact
RotateCcwrotate-leftfa-solid fa-rotate-leftexact
Rows3barsfa-solid fa-barsclose ⚠
ScrollTextscrollfa-solid fa-scrollexact
Searchmagnifying-glassfa-solid fa-magnifying-glassexact
Serverserverfa-solid fa-serverexact
Settings / Settings2gearfa-solid fa-gearexact
Shapesshapesfa-solid fa-shapesexact
Shieldshield-halvedfa-solid fa-shield-halvedexact
Sparkleswand-magic-sparklesfa-solid fa-wand-magic-sparklesclose ⚠
Squaresquarefa-regular fa-squareexact
Starstarfa-solid fa-starexact
Table2tablefa-solid fa-tableexact
TerminalSquareterminalfa-solid fa-terminalclose
TrendingUparrow-trend-upfa-solid fa-arrow-trend-upexact
Typefontfa-solid fa-fontclose
Uploaduploadfa-solid fa-uploadexact
Usersusersfa-solid fa-usersexact
Walletwalletfa-solid fa-walletexact
Xxmarkfa-solid fa-xmarkexact
ZoomInmagnifying-glass-plusfa-solid fa-magnifying-glass-plusexact
ZoomOutmagnifying-glass-minusfa-solid fa-magnifying-glass-minusexact

Alias map (ICON_NAME_ALIASES) — re-point to FA kebab names

AliasOld Lucide targetNew <Icon name>
activitiesVolleyballvolleyball
documentFileTextfile-lines
expertStethoscopestethoscope
medicalHeartPulseheart-pulse
messageMessageSquarecomment
scheduleCalendarRangecalendar-days
talkMessageSquareQuotecomment-dots
therapyBrainbrain
travelPlaneplane
check-circleCheckCircle2circle-check
help-circleCircleHelpcircle-question
alert-triangleTriangleAlerttriangle-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.

LucideChosen Free substituteNote / alternative
CircleDashedcircle-notchSpinner-like, not a true dashed ring. Only used in ato-status-badge.
Sparkleswand-magic-sparklesCarries a wand; standalone sparkles is Pro-only.
Radarsatellite-dishNearest metaphor; no radar glyph in Free.
Rows3barsGeneric; no “3 rows” glyph in Free.
LayoutGrid / LayoutTemplate / PanelLefttable-cells-large / table-columnsLayout approximations.
ImageDownimageLoses the “download” overlay; pair with a separate download if needed.
ChevronsUpDown / ChevronsDownUpsort (or angles-up/angles-down)Barrel-only; not used by any consumer today.
GripVerticalellipsis-verticalgrip-vertical is Pro-only. Barrel-only.
GitCommitHorizontalcode-commitClose; 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:

Terminal window
pnpm run lint
pnpm run typecheck
pnpm run build
pnpm run test # WS-0 and WS-5 especially
pnpm 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:

  1. Add the new library’s package(s).
  2. Write a new IconAdapter in src/lib/icon-adapters/<lib>.ts (implement has/render/list) with an explicit, tree-shaken icon map.
  3. Point icons.tsx at the new adapter (one line), or register it under a prefix for multi-library mode (§2.5).
  4. Update the concept→name map + ICON_NAME_ALIASES if names differ.
  5. Run the §7 gates + the visual pass.
  6. 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

  1. WS-0 (foundation) — one agent, merges first.
  2. WS-1, WS-2, WS-3, WS-4, WS-6 — up to five agents in parallel off WS-0.
  3. WS-5 (cleanup + gate) — one agent, last, after 1–4 land.
  4. Visual pass + merge.