Agent docs

Icon

The registry that stands between every component and react-icons.

The registry that stands between every component and react-icons. Components never import react-icons directly or reach for a raw SVG; they take an IconRender and let RenderIcon dispatch it.

The canon behind it. docs/design-language/02-buttons-and-actions.md §A8 (02-buttons-and-actions.md:40, icons come from the registry, never a callsite react-icons import) and docs/design-language/00-overview.md model 3 (00-overview.md:68, the kit is generic, the app supplies meaning, which is why the registry ships keyed concepts rather than fixed pictures).

When to reach for it#

Reach for the registry any time a surface needs a glyph: pass a key from IconFor to any component's Icon prop, or call RenderIcon directly for a bare icon with no wrapping component (both apps do this for inline icons beside text, ImportParts.tsx:266, 270, 337, Home.tsx:106, 267, 278). Adding a new concept means adding a key to IconFor first, in packages/ui/src/icon/icon-for.ts; call sites should never import a react-icons package on their own.

Do not reach for a raw element or component reference as the default path. IconRender accepts one (Icon={<CustomThing />} or Icon={SomeComponent}), and both apps use it when the visual is not really an icon at all, for example a colored + tile passed as Dropzone's Icon (ImportParts.tsx:308-312, see dropzone.md) or the animated IngestionIcon for an in-progress empty state (empty-state.md). That is the escape hatch for a one-off graphic, not the first choice for an ordinary glyph that the registry already names or should.

Contract#

The registry: IconFor (icon-for.ts) is a Record<string, RegistryIcon> of react-icons function components, grouped by a comment banner (navigation / actions / status / data / files / misc). It currently holds 72 keys, not the ~44 an earlier draft of this doc counted; count it yourself with Object.keys(IconFor).length before citing a number, since the registry grows with every new concept. IconKeys is keyof typeof IconFor; every string literal a component's Icon prop accepts is one of these keys.

The dispatcher: RenderIcon (render-icon.tsx) turns whatever an IconRender prop holds into an actual node:

  • A string is looked up in IconFor. An unknown key falls back to the NotFound icon and, in development only, logs a trace and adds a visible ping animation, so a typo'd key is loud in dev and quiet in production rather than crashing either way (render-icon.tsx:64-77).
  • A JSX element passed directly is returned as-is.
  • A component reference (any other react-icons-shaped function component) renders directly, unwrapped from the registry.
  • null or a boolean renders nothing; the meaning of passing true/false is left to whichever component reads the prop (see IconImportProps below), not defined by RenderIcon itself.
  • The LoadingIcon sentinel, or loading={true} (or loading as a node to substitute one loading render for another), renders the spinner instead of an icon. Only used internally so far: page-states.tsx, toast.tsx, and button.tsx all pass it; no shipped call site in either app reaches for LoadingIcon directly.
  • Every icon renders at a floor of 16px even when a smaller size is requested, scaled down visually instead of shrinking the underlying SVG (render-icon.tsx:46-47, 59-61), because some glyphs distort below their native size.

The standard prop group: components that accept an icon spread IconImportProps (Icon, iconSize, iconProps, iconClassName) rather than inventing their own shape each time. iconProps forwards extra props straight to the underlying SVG component (strokeWidth, aria-label, and so on); it is consumed inside the kit (list-item.tsx:75) but no shipped call site in either app passes it. StatusPill is a partial exception worth flagging: its type extends IconImportProps, so iconProps type-checks on it, but status-pill.tsx never destructures or forwards the value, so passing it there is currently a silent no-op.

Backing decision#

react-icons (5.7.0), not lucide-react. The enhancement-product app's curated icon map (frontend/src/utils/icons.ts, roughly 150 keys across the bi/bs/fa/fa6/md/pi/tb/lu/ri react-icons sub-packages) is the source of truth for which glyph each concept uses, so this registry mirrors those glyph choices under its own key names. Where a key has no equivalent in that older map, it borrows the same-named icon from react-icons/lu (react-icons' repackaging of lucide-react), keeping the glyph family the old map already favors rather than introducing a new one. LucideIcon still exists as a deprecated alias for RegistryIcon (icon-for.ts:42) so any lingering import keeps compiling; no shipped usage found for it in either app.

A registry key can carry meaning, not just a picture. FieldChip needed an icon for its "file" tone. The registry gained FileSheet (LuFileSpreadsheet) instead of reusing Folder (icon-for.ts:111, commit 0ed5005). The commit message states the reasoning: files in this product are spreadsheets, CSV, Excel, ACES, PIES, and a folder was always the wrong picture for them. Speedway's page-identity map picks up that same key for its Files nav entry (speedway/app/lib/page-meta.ts:11, Icon: "FileSheet"). Walmart has no shipped usage of FileSheet as of this pass.

Sanctioned combinations#

CombinationProducesWhere usedWhy
A registry key string passed to a component's Icon propThe ordinary case: a named concept renders its house glyphNearly every Icon="..." call site in both apps (72 keys covering navigation, actions, status, data, files, misc)The default path; add a key to IconFor rather than importing react-icons at the call site
A raw JSX element passed as Icon for a graphic the registry has no single glyph forA custom accent tile that still satisfies the same IconRender-accepting propImportParts.tsx:308-312 (a colored + tile as Dropzone's Icon)IconRender's element branch exists specifically so a component's icon slot can host a one-off without a parallel prop
<RenderIcon Icon={key} size={n} className="..." /> called directly, with no wrapping icon-aware componentAn inline glyph beside text, in a tooltip, or in a custom rowImportParts.tsx:266, 270, 337, 378, Home.tsx:106, 267, 278Not every icon placement is inside a component that already accepts IconImportProps; RenderIcon is exported for exactly this case
IconFor.FileSheet imported and rendered directly as a component, bypassing the string-key pathAn icon reference used where a component reference (not a string) is expectedspeedway/app/components/file-chip.tsx:62, part-table.tsx:405, JobsTable.tsx:570RegistryIcon values are ordinary react-icons components; nothing forces every usage through the string-key/registry-lookup path once the module is already imported

Banned combinations#

Do not import a react-icons package directly at a component call site. Every icon-accepting component in the kit takes an IconRender; a direct react-icons import bypasses the registry's one-glyph-per-concept guarantee and the dev-time "unknown key" warning that catches typos.

Do not treat the registry's key count as fixed. This doc previously stated "~44 keys"; the true count is 72 as of this pass, and it will drift again. Count Object.keys(IconFor).length rather than citing a remembered number. The 2026-08-16 promotion pass (owner kit ruling 4, from the U10 icon scan of both apps) added three keys the scan found as the only cross-app candidates: InputSearch, InputSpark, InputForm, the three frames of speedway's ingestion "reading files" cycle (speedway/app/components/IconCycle.tsx:2-3,71); everything else either already resolved to a kit key or was an app-specific concept map.

Do not assume passing iconProps to StatusPill does anything. The prop type-checks because StatusPillProps extends IconImportProps, but status-pill.tsx does not read it; see Contract above.

Before you adopt this#

Five questions to answer before reaching for the icon registry.

  1. Does the shell, a parent layout, or a global provider already render this? Not applicable, the icon registry is code, not a rendered shell surface.
  2. Does this app already ship a local implementation of the same thing? A direct react-icons import at a call site is the local-clone trap this bans.
  3. Does this app's kit pin reach the version this component or prop landed in? Three keys (InputSearch/InputSpark/InputForm) were promoted 2026-08-16; confirm the pin reaches them.
  4. Does the component derive its own accessible name and keyboard path, or must the call site supply them? Not applicable, icons carry no name themselves; iconProps's aria-label passes through where wired.
  5. Which canon §A rules bind this surface, and which does the composition break? §A8 routes every icon through the registry, never a callsite import.

Travels with#

IconImportProps is the shared shape components spread to accept Icon/iconSize/iconProps/iconClassName uniformly; grep for & IconImportProps before inventing a parallel icon prop on a new component.

RenderIcon backs Dropzone's zone icon (dropzone.md), StatusPill's leading icon (status.md), and the loading-icon path inside Button and Toasts.

LoadingIcon is how a component signals its own icon slot should show the spinner instead of a glyph; it is an internal convention between kit components today, not yet a pattern exercised from app code.

Snippet#

// speedway/app/lib/page-meta.ts:11
files: { label: "Files", Icon: "FileSheet" },
// walmart-mvp/frontend/src/pages/ImportParts.tsx:265-271
<RenderIcon Icon="Workflow" size={18} className="text-primary mt-0.5 shrink-0" />
{/* ...copy... */}
<RenderIcon Icon="Warning" size={18} className="text-warning mt-0.5 shrink-0" />
@versable-git/ui · reference, canon, and method, read in place