@paradox-design/icons
v0.2.0
Published
Paradox Design System icon registry — default icon set included, replaceable as a whole
Maintainers
Readme
@paradox-design/icons
The icon registry of Paradox Design System.
Works out of the box. <pdx-icon> renders icons from the Paradox icon set
(@paradox-design/icon-set) without any configuration. A client with its
own icons calls setIconSet() once — and from then on our set is ignored entirely.
Sets are never mixed
An icon set is a whole unit. setIconSet() replaces the active set; it does not extend it.
An icon missing from the client's set is missing — it does not silently fall back to
ours. Two drawing styles in one interface are worse than a visible gap, which is reported
by the pdx-icon-error event and a console warning.
The default set is loaded lazily, one module per icon: a client who switches to their own set downloads none of our icons.
Installation
npm install @paradox-design/iconsThe only runtime dependency is @paradox-design/icon-set. The package depends neither on
Lit nor on any framework.
Default set — nothing to do
<pdx-icon name="check"></pdx-icon>The list of names lives in @paradox-design/icon-set (iconNames) and in the Storybook
gallery.
Your own set
A record of SVG strings
import { setIconSet } from '@paradox-design/icons';
setIconSet({
save: '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/></svg>',
});A record is sanitized once, up front: an invalid icon fails at configuration time, and the previously active set stays in place.
A set available as modules
import { setIconSet, createLazyResolver } from '@paradox-design/icons';
setIconSet(
createLazyResolver({
save: () => import('./icons/save.svg?raw').then((m) => m.default),
trash: () => import('./icons/trash.svg?raw').then((m) => m.default),
}),
);A module is imported only on the first use of the icon, and exactly once.
Files on a server
import { setIconSet, createFetchResolver } from '@paradox-design/icons';
setIconSet(createFetchResolver({ url: '/assets/icons/{name}.svg' }));SVG sprite
import { setIconSet, createSpriteResolver } from '@paradox-design/icons';
setIconSet(createSpriteResolver({ url: '/assets/icons.svg', idPrefix: 'icon-' }));Beware of classic sprites.
<use href="sprite.svg#id">does not work inside Shadow DOM in Chrome or Safari, and every component of this design system uses Shadow DOM. That is whycreateSpriteResolverfetches the file and extracts individual symbols from it instead of relying on an external reference. The sprite is fetched once, regardless of how many icons are used.
Names different from the design system's
The most common case in practice: components ask for close, the client's set has x.
import { setIconSet, createAliasResolver } from '@paradox-design/icons';
setIconSet(createAliasResolver({ close: 'x', trash: 'trash-2' }, clientIconSet));Without this layer the set's names leak into application code, and changing the icon
vendor becomes a project-wide refactor. chainResolvers combines several sources of
your own into one set — it is still one set passed to setIconSet().
When to call setIconSet()
Once, before the first render. Calling it later works — every rendered <pdx-icon>
re-resolves its icon — but users see our icons flash first, so the registry warns about
it in the console.
Results are cached per set — negative results too. Changing the set clears the cache, and a request that was still in flight for the previous set never lands in the new one.
onIconSetChange(listener) notifies about set changes; <pdx-icon> uses it, and framework
wrappers can do the same. resetIconSet() restores the default set.
Security
Every icon is sanitized before it reaches the DOM. This is not excessive caution —
SVG can execute code in several ways, and <script> is merely the most obvious one:
| Vector | What it does |
| ----------------------------------------- | -------------------------------- |
| <svg onload="..."> | event handler |
| <a href="javascript:..."> | javascript: protocol |
| <foreignObject><iframe> | arbitrary HTML inside SVG |
| <set attributeName="onload" to="..."> | animation replacing an attribute |
| <use href="https://other.host/x.svg#a"> | remote content in an attribute |
The sanitizer works with an allow-list, not a deny-list. A deny-list always loses —
one unknown vector is enough. Everything not on the list is removed, and removals are
reported via console.warn with the icon name.
Sanitization can be disabled for content compiled into the build. The default set does not
need it — the icon-set build validates every icon structurally:
setIconSet(ownIconSet, { trusted: true });Never set
trustedfor content coming from a client, a CMS, an API or any user-provided configuration. That is exactly the case the sanitizer protects against.
Accessibility
The sanitizer sets aria-hidden="true" and focusable="false" on every icon.
An icon is a presentational element — accessibility is the responsibility of the
component that embeds it, through aria-label on its host. If the label lived inside
the SVG, a screen reader would announce it twice.
The sanitizer also adds fill="currentColor" when the icon does not set its own color —
so the icon inherits the text color and follows the theme with no extra configuration.
API
| Function | Description |
| ---------------------- | --------------------------------------------------- |
| setIconSet | replaces the active icon set entirely |
| resetIconSet | restores the default Paradox set |
| onIconSetChange | subscribes to set changes; returns an unsubscribe |
| resolveIcon | asynchronously returns an icon or null |
| getIconSync | synchronous read — only for icons already available |
| hasIcon | whether an icon is already available synchronously |
| getIconRegistryStats | active set (default/custom) and cache size |
| resetIconRegistry | restores the initial state; intended for tests |
| sanitizeSvg | the sanitizer, used standalone |
| createFetchResolver | icons over HTTP |
| createSpriteResolver | icons from an SVG sprite |
| createLazyResolver | icons from lazy imports |
| createAliasResolver | maps semantic names to the set's names |
| chainResolvers | combines your own sources into one set |
Multiple copies of the package
Registry state lives on globalThis under a global symbol, not in the module. With an
unlucky dependency tree an application may load two copies of this package — a plain
module singleton would then give two independent registries. The symptom ("wrong icons",
even though the set was configured) leads to an investigation that takes hours.
Tests
pnpm test:unitTests run in real Chromium, not jsdom. The sanitizer relies on DOMParser in
image/svg+xml mode, and DOM simulations differ from browsers exactly where this code
is tested: namespace handling, xlink: attributes and recovery from parse errors.
A green test in jsdom would prove nothing about security code.
License
MIT © Paradox Software
