Skip to content

feat: icon registry with lucide replacing radix icons - #883

Open
rohanchkrabrty wants to merge 4 commits into
mainfrom
multi-icon-lib
Open

feat: icon registry with lucide replacing radix icons#883
rohanchkrabrty wants to merge 4 commits into
mainfrom
multi-icon-lib

Conversation

@rohanchkrabrty

Copy link
Copy Markdown
Contributor

Summary

  • Apsara now refers to every icon by a stable name held in one map, packages/raystack/icons/icon-map.json. Codegen turns it into 243 per-icon modules, so changing icon library later is a one-file edit. The generated modules are gitignored and rebuilt by prepare, prebuild, predev, pretest and CI.
  • Breaking: lucide replaces @radix-ui/react-icons as the default library, added as a narrow peer dependency (>=0.500.0 <0.600.0). Eight icons draw a different shape, and icons now render at 16×16 with strokeWidth={1.5} — which is the design's 1px stroke, since the prop counts units of lucide's 24-unit viewBox.
  • Consumers can replace any icon with <Theme icons={…}> and set props for all of them with <Theme iconProps={…}>. Maps are partial and layer per name across nested <Theme>s, the context holds overrides only so unused icons stay tree-shakeable, and every icon renders data-icon="<Name>" for styling from CSS without a re-render.
  • @raystack/apsara/icons stays supported and is now the recommended path for icons: it reaches no component module, so one icon costs one icon even when a bundler ignores "sideEffects": false. The nine legacy in-house names are removed rather than aliased, since the release is breaking anyway.
  • Adds an Icons docs section (Usage, plus All icons with a searchable gallery and a size/stroke/colour customizer), a migration guide with a copy-paste map that restores the radix appearance, and pnpm check:icon-map in the test and both release workflows — it catches a dropped lucide export, which the build cannot, because lucide is an external and the build does not fail on type errors.

rohanchkrabrty and others added 3 commits August 7, 2026 15:01
Covers the breaking changes (lucide peer dependency, the eight icons that
change shape, the 16x16 / strokeWidth 1.5 base props, client-component
registration, the nine removed in-house names), the new override API, and
the recommendation to import icons from @raystack/apsara/icons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuBECZxRRYnorQ9nvJ1U5E
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
apsara Ready Ready Preview Aug 7, 2026 10:19am

@coderabbitai

This comment was marked as outdated.

coderabbitai[bot]

This comment was marked as outdated.

@ravisuhag

ravisuhag commented Aug 8, 2026

Copy link
Copy Markdown
Member

Out of 243 icons only 26 of icons are used by Apsara's own components. The other 217 are a curated catalog for consumers, and the curation is what creates a problem: lucide has 1773 icons, so anyone needing one outside our 243 has to either PR icon-map.json and wait for a release, or import from lucide directly and hand-set size={16} strokeWidth={1.5}. Mixed usage is guaranteed in any real app, and every name we export is API we have to keep stable. The "swap libraries with a one-file edit" promise also gets harder the bigger the catalog is, since a future library won't have equivalents for everything.

Proposal: ship 26, export the factory

  1. Ship only the 26 icons the components use (25 lucide + CoPilot).

  2. Make createIcon public from @raystack/apsara/icons, with the name loosened to accept any string. Then consumers build their own map in their app, and that file gives them everything we were trying to give them with the catalog:

// src/icons.ts — the app's single place for icons
import { createIcon } from '@raystack/apsara/icons';
import { Rocket, Trash2 } from 'lucide-react';

export const RocketIcon = createIcon('Rocket', Rocket);
export const TrashIcon = createIcon('Trash', Trash2);

Correct sizing by default, data-icon for CSS, one-file swap later — same promise we keep for ourselves, now in their hands. And there's no cliff: the 1773-icon question disappears because the catalog is theirs.

  1. Split the Theme API by job:
    • Theme icons stays, but typed to the 26 built-in names only. Its real purpose is reskinning Apsara's own components — users can't reach those call sites any other way, and this is what makes white-labeling and the radix-look migration map work. (Narrow typing is safe: we can widen later without breaking, but can't narrow back.)
    • Theme iconProps applies to all icons, including user-created ones, since they read the same context. That's the one thing that must stay global or "tune stroke width in one place" breaks.

Implementation: at 26 icons, we can drop the codegen too

The map + codegen design earns its keep at 243 icons. At 26 it's machinery we don't need: the JSON map, the generator script, gitignored generated files, the prepare/prebuild/predev/pretest hooks, and the check:icon-map CI script can all go. One committed file replaces them:

// icons/icons.tsx — this file IS the map
import { Check, ChevronDown, X /* ...23 more */ } from 'lucide-react';
import { createIcon } from './create-icon';

export const CheckIcon = createIcon('CheckIcon', Check);
export const ChevronDownIcon = createIcon('ChevronDownIcon', ChevronDown);
// ...

Every property of the current design survives:

  • Library swap stays a one-file edit — this file is the map now, and editing 26 lines by hand is a five-minute job. Codegen's value was doing that 243 times.
  • Tree-shaking is unchanged for ESM via /*#__PURE__*/. The only regression is CJS consumers loading 26 tiny wrappers instead of one — negligible at this size, unlike at 243.
  • The CI check becomes unnecessary. With real imports in a committed file, a dropped lucide export fails tsc and fails the existing render tests. Ordinary tooling catches what the custom script exists to catch.
  • CoPilot becomes a plain hand-written component wrapped with createIcon — no SVG asset pipeline.

And we gain: icon code is visible in the repo and reviewable in PRs, clone && test works with no generation step, and there's no "forgot to regenerate" failure mode. If the catalog ever grows past ~50, the map + codegen design is the right tool to bring back — it's a good design, just for a bigger problem than 26 icons.

Where this goes later: native multi-library support falls out for free

If we ever want Apsara to officially support two or three icon libraries (say lucide and radix) and let people switch through Theme, this architecture already is that feature — a "native" library is just a pre-made, tested override map promoted from user-space into the package. The PR's own migration guide proves it: its copy-paste radix map is exactly this file, just living in the docs.

Each library ships as one committed file behind its own entry point, exporting a set — icons and their tuning together, since libraries don't share prop semantics (lucide is stroke-based in a 24-unit box, radix is fill-based in 15):

// @raystack/apsara/icons/radix
export const radixIconSet: IconSet = {
  icons: { CheckIcon: RadixCheck, XIcon: RadixX /* ...24 more */ },
  iconProps: { width: 16, height: 16 } // no strokeWidth: silent on what radix doesn't need,
                                       // so lucide-based custom icons keep their stroke default
};

Theme grows one prop, and switching becomes:

import { radixIconSet } from '@raystack/apsara/icons/radix';

<Theme iconSet={radixIconSet}>
  <App />
</Theme>

Each alternate library is an optional peer dependency — apps that never import the set never pull it into the bundle. And because a set is plain data, the existing layering keeps working: per-name icons overrides win over the set, iconProps merges on top, and a nested <Theme iconSet={...}> can run a different library for one section of the app.

At 26 names, a third library is 26 hand-written lines and a render test. At 243 it would be another codegen target and another 243 names to reconcile on every swap — one more reason the small catalog is the version of this design that scales in the direction we'd actually grow.

Docs: one page in the Theme section

The current docs are shaped around the big catalog — an Icons section with a Usage page and a searchable 243-icon gallery. With 26 icons and one recipe, the content collapses to a single "Icons" page, and it belongs in the Theme section, since after this change everything interesting about icons flows through Theme:

  1. Basics — components use 26 named icons, lucide-backed by default; import and use them directly.
  2. Your own icons — the createIcon + src/icons.ts recipe. The main section, since it's what most readers come for. Link to lucide.dev to browse — 1773 icons with better search than we'd ever build.
  3. Switching sets<Theme iconSet={radixIconSet}>, the optional peer dep, per-name overrides winning over the set, iconProps merging, nested Themes. Plus a "roll your own set" one-liner.
  4. The 26 built-in names — an inline grid, each icon labeled with its override name, toggleable between the two shipped sets. Replaces the gallery page; its job is "look up the name of the icon you want to replace", not "find an icon to use".
  5. API referencecreateIcon, IconProvider, iconSet/icons/iconProps, types.

The migration guide stays its own document (release-specific, will age out), and gets shorter: "restore the radix appearance" becomes one line with iconSet, plus one new step — "if you imported an icon we no longer ship, add it to your own icons.ts with createIcon". The 600-line gallery component and the Icons nav section go away.

What we give up: the ready-made catalog and the big gallery page. The docs become the 26 built-in names plus the recipe above. In exchange the public API drops from 243 names to 26 plus one factory, users own their icon set with no gap, and the next library migration touches a fraction of the surface.

@ravisuhag

Copy link
Copy Markdown
Member

We should also use lucide sparkle icon instead of custom Copilot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants