Part of the Dashboard Overview #77616.
A widget's icon is the only piece of its visual identity that no layer of the pipeline can see.
This proposes carrying it as a declarative reference, resolved in the same place the widget pipeline already resolves declarative references.
Problem
Icons are authored in the widget's JS module, as a React element imported from @wordpress/icons:
import { calendar } from '@wordpress/icons';
export default { name: 'core/events', icon: calendar };
Every other identity field travels through widget.json into the build manifest, the PHP registry, and wp/v2/widget-modules. The icon does not. Anything that wants a widget's icon has to import and execute the widget's metadata module first, so an inserter listing fifty widget types pays fifty dynamic imports to draw fifty icons. A widget that ships no JS module cannot declare an icon at all, and nothing server-side can render one.
The reason was that an icon is a React element, not a serializable value. That is no longer the case.
What changed
Core's Icons API stabilized the registry, the wp/v2/icons endpoint, and the Icon block in 7.0, and the 7.1 iteration (#75715, Core ticket #64847) added the public registration functions in #77260. An icon is now addressable by a namespaced collection/icon-name string, resolvable server-side with wp_get_icon() and client-side through the icon core-data entity (packages/core-data/src/entities.js:242-251).
The Icon block is the precedent for storing one: a plain string attribute in packages/block-library/src/icon/block.json, resolved with getEntityRecord( 'root', 'icon', name ) at packages/block-library/src/icon/edit.js:80.
Widgets would be first to use this in a metadata file. block.json's icon is still a Dashicons slug.
Contract
One nullable string field, three authoring forms, told apart by their own shape. A leading < is markup, a .svg suffix is a file in the widget directory, anything else is a registered icon name. Icon names admit neither dots nor angle brackets, so the three never collide and no discriminator key is needed.
{ "icon": "core/shield" }
{ "icon": "<svg viewBox=\"0 0 24 24\">…</svg>" }
{ "icon": "icon.svg" }
The React element in widget.ts stays as the fourth form, following the override semantics already in place: a server value wins, and the module's value stands when there is none (packages/widget-primitives/src/types.ts:283-286). Widgets in tree declare no icon in JSON, so their elements keep rendering untouched.
No i18n work. An icon's label lives in the icon registry, already translated, so widget-i18n.json is untouched.
Resolution belongs in useWidgetTypes
The package already does exactly this for attribute field types, and the reasoning transfers whole. From packages/widget-primitives/src/stories/field-types.md: the application registers what a name means, the widget references the name, and the hook "is the only point in the system that recognizes the names; hosts and DataForm downstream see native DataViews vocabulary and nothing else". The type comment says the same, useWidgetTypes is "the single boundary" that turns records into a WidgetType (packages/widget-primitives/src/types.ts:230-236).
An icon name is the same kind of reference, so it resolves at the same boundary. Hosts receive a renderable icon and never see a name, the way they already receive plain DataViews fields and never see location.
One difference shapes the implementation. A field type is code the host owns and registers synchronously at init (routes/dashboard/field-types/index.ts:29, called at routes/dashboard/stage.tsx:26). An icon is data behind a REST entity, so what the host registers is a resolver rather than a value. The hook is already asynchronous, it awaits import( record.widget_module ) inside a Promise.all and exposes isResolving, so awaiting icon resolution in the same pass costs no new machinery.
This keeps @wordpress/widget-primitives host-neutral, which is why it can only be done this way: the package depends on @wordpress/dataviews and @wordpress/element and nothing else, so it cannot read the icon entity itself. The host registers the resolver next to its field types, closing over core-data; the package stays a contract.
Two consequences worth stating up front:
- If the resolver returns a React element,
WidgetType.icon keeps its current type and nothing downstream changes at all, including the dashboard chrome. Whether the resolved representation is an element or markup is the one open decision here.
- An unregistered or unresolvable name degrades silently and leaves the widget without an icon, matching the rule field types already follow.
Registration timing
Whatever registers the resolver has to do it before the hook resolves. Which level owns that is not settled and does not need to be for this to proceed. @wordpress/dashboard-init runs before the page renders and registers the widgetModule entity there (packages/dashboard-init/src/index.ts:15-31); field types register at module scope in the route instead (routes/dashboard/stage.tsx:26). Both are valid homes today, and the vocabulary may well move to a different level as more hosts appear.
What the contract depends on is the guarantee rather than the location: the resolver is in place by the time a host consumes useWidgetTypes, and a reference that is not yet resolvable degrades to no icon instead of breaking. That is what makes moving the registration level later a non-event.
Step 1: declarative icon by registered name
| Layer |
File |
Change |
| Build typedef |
packages/wp-build/lib/widget-utils.mjs:47-58 |
icon in WidgetMetadata |
| Manifest |
packages/wp-build/lib/build.mjs:2039-2053 and :2205-2218 |
Carry icon into build/widgets/registry.php |
| Type object |
lib/experimental/dashboard-widgets/class-wp-widget-type.php |
public $icon |
| Registration |
lib/experimental/dashboard-widgets/widget-types.php:266-282 |
Validate the name shape, drop what does not match |
| REST |
lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php:182-220, schema at :241 |
Expose icon |
| Client types |
packages/widget-primitives/src/types.ts:32, :287-303 |
WidgetIcon accepts a string; icon joins the record as string | null, declared outside the Pick<> so a ReactElement cannot enter a wire type |
| Resolution |
packages/widget-primitives/src/hooks/use-widget-types.ts:66-99 plus a new registry beside field-types/ |
Resolve the reference while assembling each WidgetType |
| Host |
routes/dashboard/field-types/index.ts and routes/dashboard/stage.tsx:26 |
Register an icon resolver reading the icon entity |
| Schema |
schemas/json/widget.json |
New icon property (#77640) |
Step 2: the widget.ts element
The element form works today. This is about making the two coexist deterministically once the JSON field exists, and saying so in the types rather than leaving it implicit in the merge.
Step 3: SVG
For icons the registry does not carry. Both forms resolve to sanitized markup at registration, so nothing changes client-side beyond accepting markup where a name was expected.
Sanitization reuses wp_kses() with an SVG allow-list, which is what the Icons API itself does in sanitize_icon_content() (lib/compat/wordpress-7.0/class-wp-icons-registry.php:182-208, borrowed from twentytwenty_get_theme_svg). It lives in lib/experimental/dashboard-widgets/widget-types.php next to gutenberg_sanitize_widget_help() and gutenberg_sanitize_widget_actions(), the registration gate that graduates into Core with the rest of the feature. Matching the allow-list keeps widget icons and registered icons on the same rules and needs no new Core function. The list is narrow, svg, path and polygon only, which is what @wordpress/icons uses.
Note on icons that are not registered
packages/icons/src/manifest.json carries 339 icons, but only the 88 marked public: true reach the core/ collection (packages/icons/lib/generate-manifest-php.cjs:62-67). Of the six widgets that import an icon, three resolve by name today: calendar (Events), shield (Site Health), audio (Hello Dolly). Three do not: wordpress (Hello World), drafts (Quick Draft), site-logo (Site Preview).
Promoting those to public is not the answer, since public also means selectable in the Icon block and shipped in the icon library, which is a product decision about the library rather than about widgets. Two ways forward instead:
- Use a registered icon. The public set covers all three plausibly:
create or pencil for Quick Draft, desktop for Site Preview, block-default for Hello World, which is a demo widget.
- Register our own.
wp_register_icon( 'collection/name', [ 'label' => …, 'content' => '<svg…>' ] ) accepts markup or a file_path. Under a widget-owned collection rather than core/, which the API reserves for WordPress core icons and which stays equal to the icons manifest.
Either way Step 2 keeps the module element working, so no widget is blocked on this.
Related
Part of the Dashboard Overview #77616.
A widget's icon is the only piece of its visual identity that no layer of the pipeline can see.
This proposes carrying it as a declarative reference, resolved in the same place the widget pipeline already resolves declarative references.
Problem
Icons are authored in the widget's JS module, as a React element imported from
@wordpress/icons:Every other identity field travels through
widget.jsoninto the build manifest, the PHP registry, andwp/v2/widget-modules. The icon does not. Anything that wants a widget's icon has to import and execute the widget's metadata module first, so an inserter listing fifty widget types pays fifty dynamic imports to draw fifty icons. A widget that ships no JS module cannot declare an icon at all, and nothing server-side can render one.The reason was that an icon is a React element, not a serializable value. That is no longer the case.
What changed
Core's Icons API stabilized the registry, the
wp/v2/iconsendpoint, and the Icon block in 7.0, and the 7.1 iteration (#75715, Core ticket #64847) added the public registration functions in #77260. An icon is now addressable by a namespacedcollection/icon-namestring, resolvable server-side withwp_get_icon()and client-side through theiconcore-data entity (packages/core-data/src/entities.js:242-251).The Icon block is the precedent for storing one: a plain string attribute in
packages/block-library/src/icon/block.json, resolved withgetEntityRecord( 'root', 'icon', name )atpackages/block-library/src/icon/edit.js:80.Widgets would be first to use this in a metadata file.
block.json'siconis still a Dashicons slug.Contract
One nullable string field, three authoring forms, told apart by their own shape. A leading
<is markup, a.svgsuffix is a file in the widget directory, anything else is a registered icon name. Icon names admit neither dots nor angle brackets, so the three never collide and no discriminator key is needed.{ "icon": "core/shield" } { "icon": "<svg viewBox=\"0 0 24 24\">…</svg>" } { "icon": "icon.svg" }The React element in
widget.tsstays as the fourth form, following the override semantics already in place: a server value wins, and the module's value stands when there is none (packages/widget-primitives/src/types.ts:283-286). Widgets in tree declare no icon in JSON, so their elements keep rendering untouched.No i18n work. An icon's label lives in the icon registry, already translated, so
widget-i18n.jsonis untouched.Resolution belongs in
useWidgetTypesThe package already does exactly this for attribute field types, and the reasoning transfers whole. From
packages/widget-primitives/src/stories/field-types.md: the application registers what a name means, the widget references the name, and the hook "is the only point in the system that recognizes the names; hosts and DataForm downstream see native DataViews vocabulary and nothing else". The type comment says the same,useWidgetTypesis "the single boundary" that turns records into aWidgetType(packages/widget-primitives/src/types.ts:230-236).An icon name is the same kind of reference, so it resolves at the same boundary. Hosts receive a renderable icon and never see a name, the way they already receive plain DataViews fields and never see
location.One difference shapes the implementation. A field type is code the host owns and registers synchronously at init (
routes/dashboard/field-types/index.ts:29, called atroutes/dashboard/stage.tsx:26). An icon is data behind a REST entity, so what the host registers is a resolver rather than a value. The hook is already asynchronous, it awaitsimport( record.widget_module )inside aPromise.alland exposesisResolving, so awaiting icon resolution in the same pass costs no new machinery.This keeps
@wordpress/widget-primitiveshost-neutral, which is why it can only be done this way: the package depends on@wordpress/dataviewsand@wordpress/elementand nothing else, so it cannot read theiconentity itself. The host registers the resolver next to its field types, closing overcore-data; the package stays a contract.Two consequences worth stating up front:
WidgetType.iconkeeps its current type and nothing downstream changes at all, including the dashboard chrome. Whether the resolved representation is an element or markup is the one open decision here.Registration timing
Whatever registers the resolver has to do it before the hook resolves. Which level owns that is not settled and does not need to be for this to proceed.
@wordpress/dashboard-initruns before the page renders and registers thewidgetModuleentity there (packages/dashboard-init/src/index.ts:15-31); field types register at module scope in the route instead (routes/dashboard/stage.tsx:26). Both are valid homes today, and the vocabulary may well move to a different level as more hosts appear.What the contract depends on is the guarantee rather than the location: the resolver is in place by the time a host consumes
useWidgetTypes, and a reference that is not yet resolvable degrades to no icon instead of breaking. That is what makes moving the registration level later a non-event.Step 1: declarative icon by registered name
packages/wp-build/lib/widget-utils.mjs:47-58iconinWidgetMetadatapackages/wp-build/lib/build.mjs:2039-2053and:2205-2218iconintobuild/widgets/registry.phplib/experimental/dashboard-widgets/class-wp-widget-type.phppublic $iconlib/experimental/dashboard-widgets/widget-types.php:266-282lib/experimental/dashboard-widgets/class-wp-rest-widget-modules-controller.php:182-220, schema at:241iconpackages/widget-primitives/src/types.ts:32,:287-303WidgetIconaccepts a string;iconjoins the record asstring | null, declared outside thePick<>so aReactElementcannot enter a wire typepackages/widget-primitives/src/hooks/use-widget-types.ts:66-99plus a new registry besidefield-types/WidgetTyperoutes/dashboard/field-types/index.tsandroutes/dashboard/stage.tsx:26iconentityschemas/json/widget.jsoniconproperty (#77640)iconthrough build, PHP registry, and REST, validating the name at registrationuseWidgetTypesiconentityicontoschemas/json/widget.json(Widgets: Addwidget.jsonmetadata schema #77640) and adopt it in the corewidget.jsonfilesfield-types.md, which is its templateStep 2: the
widget.tselementThe element form works today. This is about making the two coexist deterministically once the JSON field exists, and saying so in the types rather than leaving it implicit in the merge.
useWidgetTypeswidgets/welcome/widget.ts:4, which declaresicon: 'wordpress'as a string against aReactElementtype. It renders an empty SVG rather than failing, unnoticed because Welcome isfull-bleedand draws no header. The same file carries a deadapiVersion: 1and duplicatescategoryfrom itswidget.jsonStep 3: SVG
For icons the registry does not carry. Both forms resolve to sanitized markup at registration, so nothing changes client-side beyond accepting markup where a name was expected.
Sanitization reuses
wp_kses()with an SVG allow-list, which is what the Icons API itself does insanitize_icon_content()(lib/compat/wordpress-7.0/class-wp-icons-registry.php:182-208, borrowed fromtwentytwenty_get_theme_svg). It lives inlib/experimental/dashboard-widgets/widget-types.phpnext togutenberg_sanitize_widget_help()andgutenberg_sanitize_widget_actions(), the registration gate that graduates into Core with the rest of the feature. Matching the allow-list keeps widget icons and registered icons on the same rules and needs no new Core function. The list is narrow,svg,pathandpolygononly, which is what@wordpress/iconsuses..svgpaths against the widget directory, reusing the traversal rules ingutenberg_resolve_widget_action_href()(widget-types.php:126-164)Note on icons that are not registered
packages/icons/src/manifest.jsoncarries 339 icons, but only the 88 markedpublic: truereach thecore/collection (packages/icons/lib/generate-manifest-php.cjs:62-67). Of the six widgets that import an icon, three resolve by name today:calendar(Events),shield(Site Health),audio(Hello Dolly). Three do not:wordpress(Hello World),drafts(Quick Draft),site-logo(Site Preview).Promoting those to public is not the answer, since public also means selectable in the Icon block and shipped in the icon library, which is a product decision about the library rather than about widgets. Two ways forward instead:
createorpencilfor Quick Draft,desktopfor Site Preview,block-defaultfor Hello World, which is a demo widget.wp_register_icon( 'collection/name', [ 'label' => …, 'content' => '<svg…>' ] )accepts markup or afile_path. Under a widget-owned collection rather thancore/, which the API reserves for WordPress core icons and which stays equal to the icons manifest.Either way Step 2 keeps the module element working, so no widget is blocked on this.
Related
widget.jsonmetadata schema #77629 (widget.jsonmetadata schema)widget.jsonmetadata schema #77640 (schema publication)