Skip to content

feat: add createIcon factory to decouple Icon from a single icon set#786

Draft
adrienzheng-cb wants to merge 7 commits into
masterfrom
adrien/create-icon-seam-example
Draft

feat: add createIcon factory to decouple Icon from a single icon set#786
adrienzheng-cb wants to merge 7 commits into
masterfrom
adrien/create-icon-seam-example

Conversation

@adrienzheng-cb

@adrienzheng-cb adrienzheng-cb commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Example PR demonstrating the createIcon seam ("Option B") from the CDS Icon Style Update TDD. Backward-compatible infrastructure only — no new icon assets.

What changed? Why?

The web and mobile Icon components hardcode a single icon source (@coinbase/cds-icons/glyphMap), a single font family ('CoinbaseIcons'), and a single name type (IconName). That makes it impossible to render an alternate icon set (e.g. a private, retail-specific style) without forking the whole component.

This PR extracts the rendering logic into a createIcon<Name> factory (mirroring the existing createIllustration<Variant> convention) that binds:

  • a glyph map (GlyphMap<Name>, keys `${name}-${size}-${active}`),
  • an injectable font family (defaults to 'CoinbaseIcons'),
  • a typed name union (Name), and
  • an optional glyph resolver (getGlyph) for sets with a different key format or size model.

The default Icon becomes a one-liner:

export const Icon = createIcon<IconName>({ glyphMap, fontFamily: DEFAULT_ICON_FONT_FAMILY });

So the public API and behavior are unchanged, while a consumer with their own icon package can do createIcon<RetailIconName>({ glyphMap, fontFamily }) and get a fully-typed icon component that reuses all CDS accessibility, theming, sizing, and componentConfig behavior — no duplicated component logic.

Glyph resolution

Rather than a narrow getSourceSize hook that only remapped the pixel size, the factory accepts a general getGlyph resolver: (args: IconGlyphResolverArgs<Name>) => string | undefined, where args carry glyphMap, name, size token, resolved pixelSize, and active. The default resolver keeps the CDS `${name}-${sourceSize}-${'active' | 'inactive'}` scheme (sourceSize = 12/16/24), so nothing changes for the default Icon; consumers whose maps are keyed differently can override the entire lookup.

Font family (web)

On web, font-family is applied through a --cds-icon-font-family CSS custom property (default 'CoinbaseIcons', declared in the static Linaria block). createIcon only sets the variable inline when a non-default font is bound, so the default keeps its fully static CSS while consumers can override the font per instance via className/styles or by scoping the variable on an ancestor. Mobile applies fontFamily via its inline style object (React Native has no CSS variables).

Docs

The Icon docs page gains a "Custom icon sets" section (web + mobile) documenting the factory, the glyph map contract, the getGlyph override, and font loading. The web page includes an editable live demo that binds createIcon to Google Material Icons to prove a second, fully-typed icon set works end-to-end.

Root cause (required for bugfixes)

N/A — additive infrastructure.

UI changes

None. The default icon set, font, glyph keys, and sizing are identical, so rendering is unchanged.

Testing

How has it been tested?

  • Unit tests
  • Interaction tests
  • Pseudo State tests
  • Manual - Web
  • Manual - Android (Emulator / Device)
  • Manual - iOS (Emulator / Device)

Testing instructions

  • yarn nx run-many --target=typecheck --projects=web,mobile — pass
  • yarn nx run web:test --testPathPattern="createIcon.test" — pass (4 tests: renders glyph, sets/omits the font-family CSS variable, renders fallback)
  • yarn nx run mobile:test --testPathPattern="icons/" — pass
  • yarn nx run docs:start — docs build succeeds and the Icon page renders the live demo

Illustrations/Icons Checklist

N/A — no files under packages/icons/** or packages/illustrations/** changed; no icon assets or names added.

Change management

type=routine
risk=low
impact=sev5

automerge=false

Made with Cursor

@cb-heimdall

Copy link
Copy Markdown
Collaborator

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 1
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1
CODEOWNERS 🟡 See below

🟡 CODEOWNERS

Code Owner Status Calculation
ui-systems-eng-team 🟡 0/1
Denominator calculation
Additional CODEOWNERS Requirement
Show calculation
Sum 0
0
From CODEOWNERS 1
Sum 1

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@adrienzheng-cb
adrienzheng-cb force-pushed the adrien/create-icon-seam-example branch from 3ceafc6 to 0d6d6a6 Compare July 14, 2026 16:47
@adrienzheng-cb
adrienzheng-cb marked this pull request as draft July 14, 2026 20:39

### Custom icon sets

CDS ships a single default icon font (`@coinbase/cds-icons`). If you maintain your own icon package — with its own generated glyph map, font, and name union — use `createIcon` to build a fully-typed icon component bound to that set. It reuses all of the default `Icon`'s rendering, sizing, color, and accessibility behavior; only the glyphs, font, and `name` type change. In fact, the default `Icon` is itself `createIcon<IconName>({ glyphMap, fontFamily: 'CoinbaseIcons' })`, so this API is fully backward compatible.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with its own generated glyph map, font, and name union

This comes across as kind of confusing without knowing what any of them are.

I wonder if it would just be better to not document createIcon until we have a better way to document all the required arguments and make them a little more self-service? Otherwise i think we need to simplify by taking out some technical details and simply showing how createIcon is used. We could say in the future it may be possible to build your own icon set

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe there is a helpful doc about glyphmap icons to link to for supplemental reading

* icon component that reuses all of the CDS rendering, accessibility, and
* theming behavior.
*/
export function createIcon<Name extends string>({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need unit tests like web

const rootStyle = useMemo(
() => [
{
paddingTop: theme.space[paddingTop ?? paddingY ?? padding ?? 0],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is really interesting... i wonder what other _props should actually be going to the Box contianer and not the inner Text. I see we are not spreading anywhere so hopefully the set is small

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional — mobile createIcon destructures a fixed, closed set of props and never spreads ...props onto the Box or the inner Text, so nothing leaks through. Anything not in that set is simply not accepted. If we later want web-style Box passthrough on mobile, that'd be a separate, deliberate change.

const sourceSize = getSourceSize(iconSize);

const iconColor = theme.color[color];
const finalColor = dangerouslySetColor ?? iconColor;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: make sure this prop has a deprecation date

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if so, we could probably consider dropping it since the color prop covers this and styles.icon.color should work as well

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dropping as in the business logic, keeping the deprecated prop for bw compat but have it wired up to nothgin

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. It already has @deprecationExpectedRemoval v10. I'd prefer to keep the current wiring for now rather than turn it into a no-op early, since that's a behavior change for existing consumers still passing dangerouslySetColor. Plan is to drop the business logic (and/or the prop) as part of the v10 removal, where color and styles.icon.color fully cover it.

/**
* `createIcon` bound to a different font (Google Material Icons), showing a
* consumer can supply their own glyph map, font, and name union while reusing
* the CDS Icon renderer. Uses a network font, so percy is skipped.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you mean percy is skipped?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't include this story in visreg, because the font comes from a external source and may change and break the test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm gonna remove this comment tho, it is too trivial a detail.

@github-actions github-actions Bot added the root label Jul 15, 2026
adrienzheng-cb and others added 6 commits July 16, 2026 14:45
Extract the web and mobile Icon rendering into a `createIcon<Name>` factory
that binds a glyph map, font family, and typed name union. The default
`Icon` becomes `createIcon<IconName>({ glyphMap, fontFamily })`, so behavior
and the public API are unchanged, while consumers with their own icon
package can create a typed icon component that reuses all CDS rendering,
accessibility, and theming logic.

Font family is now injectable (moved off the static Linaria block on web to
an inline style) rather than hardcoded, enabling alternate icon sets without
forking the component.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the static Linaria block and express font-family as
`var(--cds-icon-font-family, 'CoinbaseIcons')`. The factory only sets the
variable when a non-default font is bound, so consumers can override the
icon font via className, inline style, or theme scope without a specificity
battle. Default rendering is unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
Unit tests cover the seam: rendering a glyph from a supplied map, setting
the --cds-icon-font-family CSS variable only for a custom font, and falling
back when a glyph is missing. The Storybook entry binds Google Material
Icons through createIcon to visually demonstrate a second, typed icon set
running on a different font (percy-skipped, network font).

Co-authored-by: Cursor <cursoragent@cursor.com>
Adds a "Custom icon sets" section to the web and mobile Icon docs covering
the createIcon factory, glyph map contract, and font loading, plus an
editable live demo on web. Also tightens a couple of verbose comments in
the web factory and custom-font story.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replaces the `getSourceSize` config (which only remapped the pixel size)
with a more general `getGlyph` resolver that controls how a glyph is looked
up from the map. The default resolver keeps the CDS
`${name}-${sourceSize}-${state}` scheme, so behavior is unchanged, but
consumers with a different key format or size model can now override the
whole lookup. Exports the new `IconGlyphResolverArgs` type and documents
the override on the Icon docs page.

Co-authored-by: Cursor <cursoragent@cursor.com>
Switches the custom-font Storybook demo from a story-level `percy: { skip }`
parameter to the centralized `.percy.js` exclude list, matching how other
non-deterministic (animation/network) stories are handled.

Co-authored-by: Cursor <cursoragent@cursor.com>
@adrienzheng-cb
adrienzheng-cb force-pushed the adrien/create-icon-seam-example branch from 74a94a2 to b24e5b9 Compare July 16, 2026 18:46
Address PR review feedback: add mobile createIcon unit tests mirroring
web (plus getGlyph resolver coverage), simplify the public createIcon
docs to reduce jargon, and drop a trivial Percy comment from the story.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

3 participants