Docs

This page isn't translated yet

Icon picker

Icon packs, storage format, emoji, linkable icons and custom converters.

On this page

Systhema ships with a multi-pack icon picker for PayloadCMS. The picker is embedded in the built-in Button, Chip, Icon, and Footer-social blocks and is also available as a reusable field (iconPickerField) for custom blocks. The inline Icon block also supports optional links — see Linkable icons below.

Out of the box, Font Awesome (Solid + Brands), Google Material Symbols (Outlined 400), and both emoji packs (Apple + native) are enabled. Use the icons plugin option to disable a pack, change the Material Symbols variant, or add your own SVG collections.

Plugin optionsLink to this section

Three top-level keys on SysthemaPayloadPluginOptions:

  • icons — which icon packs are available in the admin picker, and how each is configured
  • button — default icon selection applied to newly created Button blocks
  • chip — default icon selection applied to newly created Chip blocks
import { withSysthema } from '@systhemaui/payload'

export default withSysthema(baseConfig, {
  // Everything below is optional — defaults give you FA Solid + Brands,
  // Material Symbols Outlined 400, and both emoji packs without any `icons` config.
  icons: {
    fontAwesome: { styles: ['solid', 'regular', 'brands'] },
    materialSymbols: { style: 'rounded', weight: 300, fill: true },
    custom: [
      { id: 'my-pack', label: 'Company Icons', icons: { myLogo: '<svg…></svg>' } },
    ],
  },
  // Per-block default icons — stored as identifier strings
  button: { defaultIconAfter: 'fa-solid:faArrowRight' },
  chip:   { defaultIconBefore: 'material-symbols:tag' },
})

iconsLink to this section

KeyTypeDefault
fontAwesomefalse | { styles?: Array<'solid' | 'regular' | 'brands'> }{ styles: ['solid', 'brands'] }
materialSymbolsfalse | { style?: 'outlined' | 'rounded' | 'sharp'; weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700; fill?: boolean }{ style: 'outlined', weight: 400, fill: false }
appleEmojifalse | { skinTone?: 'default' | 'light' | 'medium-light' | 'medium' | 'medium-dark' | 'dark' }{ skinTone: 'default' }
nativeEmojifalse | { skinTone?: 'default' | 'light' | 'medium-light' | 'medium' | 'medium-dark' | 'dark' }{ skinTone: 'default' }
customArray<{ id: string; label: string; icons: Record<string, string>; tags?: Record<string, string[]> }>[]

Semantics:

  • Omit icons entirely → FA Solid + Brands, Material Symbols Outlined 400, and both emoji packs are active.
  • icons: { fontAwesome: false } → disables FA; Material Symbols stays on with defaults.
  • icons: { materialSymbols: false } → disables MS; FA stays on with defaults.
  • Pass a partial { style, weight, fill } on materialSymbols to change just one axis — the rest use defaults.
  • Each custom pack must declare a unique id that doesn't collide with a built-in id (fa-solid, fa-regular, fa-brands, material-symbols). tags are optional but recommended — they power the picker's search.

Font Awesome styles is a plural array. Each entry becomes its own sub-pack in the picker (Solid, Regular, Brands). All three are merged under one "Font Awesome" tab.

Material Symbols has one variant active at a time. The supported axes:

  • style — 'outlined' (default), 'rounded', 'sharp'
  • weight — 100, 200, 300, 400 (default), 500, 600, 700
  • fill — true or false (default)

Changing any of those values re-renders every stored Material Symbols icon across the site on the next page load. No DB migration, no manual re-selection — see "Icon storage format" below.

button and chip defaultsLink to this section

Accepts an icon identifier in <packId>:<iconName> format. The identifier is resolved at field-initialization time against the currently-registered packs.

  • 'fa-solid:faArrowRight'
  • 'fa-brands:faTwitter'
  • 'material-symbols:arrow_forward'
  • '<customPackId>:<name>'

If the identifier can't be resolved (pack not enabled, unknown name), Systhema logs a dev-time warning and the field stays empty. Editors can always override or clear the default; once cleared, the default is not re-applied.

Icon storage formatLink to this section

How a pick is stored in the database depends on which pack it came from:

PackStored shapeWhy
Font Awesome{"id":"fa-solid:faArrowRight","svg":"<svg…></svg>"}FA identifier pins an exact SVG — inlining it avoids a lookup on every render.
Custom{"id":"my-pack:logo","svg":"<svg…></svg>"}Same reason as FA.
Material Symbols{"id":"material-symbols:arrow_forward"} (no svg field)MS has a live variant (style/weight/fill). Storing only the identifier lets variant swaps propagate retroactively — the render path looks up the current SVG.
Legacy (pre-v1.6)Raw <svg…></svg> stringContinues to render unchanged via a compatibility path.

No DB migration is needed when upgrading from v1.5 — legacy raw-SVG strings continue to render unchanged, and FA / custom picks made during v1.6 development (which inlined svg even for MS) also still render.

How Material Symbols variant swaps workLink to this section

  1. Editor picks an MS icon in the admin → picker writes {"id":"material-symbols:home"} (no svg).
  2. Server-side render path calls resolveIconForRender() for each stored icon.
  3. For the MS identifier, the resolver reads the SVG for home out of an in-memory cache. The cache is filled once at plugin init by a separate server-only module that reads the active variant's pre-built digest file (e.g., rounded-300-1.json) from disk; the resolver itself never touches the filesystem.
  4. You change icons.materialSymbols.weight from 400 to 200 in payload.config.ts and redeploy → the resolver loads outlined-200-0.json instead, and every stored MS icon renders at weight 200.

All 42 variants (3 styles × 7 weights × 2 fills) are shipped with the package, so switching between them at any time is a config-only change.

Upgrading from 1.5Link to this section

Action required: custom Lexical convertersLink to this section

If your project has custom Lexical converters for Systhema-shaped blocks (e.g., a custom button variant) that read data.iconBefore, data.iconAfter, or data.icon directly into dangerouslySetInnerHTML, use resolveIconForRender to handle all three storage formats — including Material Symbols identifier-only:

+ import { resolveIconForRender } from '@systhemaui/payload/fields/iconPicker/resolveIconForRender'

  function MyCustomButton({ data }) {
    return (
      <button>
-       <span dangerouslySetInnerHTML={{ __html: data.iconBefore }} />
+       <span dangerouslySetInnerHTML={{ __html: resolveIconForRender(data.iconBefore).svg }} />
        {data.label}
      </button>
    )
  }

resolveIconForRender is synchronous, so the calling component needs no async plumbing.

Consumers who only use the built-in converters have nothing to change — the built-in button, chip, icon, and footer-social converters already call resolveIconForRender() internally.

Rendering icons in the browserLink to this section

Material Symbols and Apple emoji are stored as an identifier only, and the markup is looked up at render time from a digest the server loads from disk. On a published page that is invisible — the Lexical converters run on the server and the browser only ever receives the finished markup.

A tree rendered in the browser is the exception. Client-mode live preview (livePreview.mode: 'client') builds a fresh tree from the editor's live form state with no server render behind it, so those two packs would have nothing to resolve and every such pick would paint as an empty icon while the published page looked perfect.

Systhema closes that with a read-through, not a bigger bundle:

  1. The resolver records the ids it could not resolve.
  2. A client caller batches them into one GET <routes.api>/systhema/icons/resolve?ids=….
  3. The endpoint answers from the digest cache the server already holds in memory (no filesystem work per request) and honours everything a published page honours — the active Material Symbols variant, the active skin tone, and pack opt-outs.
  4. The answers go into the resolver's read-through and the tree re-renders.

Shipping the digest instead was never an option: Material Symbols alone is ~3.8 MB per variant. Serialising the server's cache into the preview payload would cover the saved document but not an icon the editor picks during the session, which is the point of live preview.

Client-mode live preview does this for you — nothing to wire up. You only need the hook if you hand-roll a browser-rendered tree of your own:

'use client'
import { RichText, useSysthemaIconResolution } from '@systhemaui/payload/next/client'

export function MyClientView({ data, apiRoute }: { data: any; apiRoute: string }) {
  // Pass your project's Payload `routes.api` — `/api` unless you changed it.
  useSysthemaIconResolution(apiRoute)
  return <RichText data={data.content} />
}

Call it from the component that owns the tree: the read-through is a plain module map, so only a state update above the tree makes newly-resolved icons paint.

Notes on the endpoint:

  • It is unauthenticated by design. Everything it returns is markup a published page already serves inline to anonymous visitors, and requiring the Payload cookie would break the case it exists for — a domain-routed multilingual project previewing a locale on a different origin from the admin, where that cookie is third-party and absent. The request is bounded by a 200-id cap.
  • An id it cannot resolve comes back absent rather than as an error, so one bad id never costs the rest of a batch — and the client marks it attempted, so an unresolvable id costs exactly one request instead of looping.
  • Font Awesome, custom packs and native emoji never reach it: the first two carry their markup in the stored value, and native emoji is pure codepoint composition.

Per-instance pack config (iconPickerField)Link to this section

When a specific picker should differ from the admin-wide config — only Material Symbols, a different MS variant, only one custom pack — pass packs to iconPickerField. The shape mirrors the plugin-level icons option:

import { iconPickerField } from '@systhemaui/payload/fields/iconPicker'

iconPickerField({
  name: 'icon',
  packs: {
    fontAwesome: false,
    materialSymbols: { weight: 200 }, // override default weight just here
    custom: [
      { id: 'company', label: 'Company', icons: { logo: '<svg…/>' } },
    ],
  },
})

When packs is omitted, the field inherits the admin-wide icons config (the default behavior). When provided, the field uses only these packs and ignores the plugin-level descriptors entirely.

Material Symbols storage in instance mode: picks made through a per-instance picker inline the SVG at pick time (instead of the identifier-only storage used by the inherited path). This is because the server-side render cache only holds the plugin-level MS variant — per-instance variants would not resolve at render time. The tradeoff: per-instance MS picks lose the variant-swap auto-propagation feature for that field. FA and custom picks behave identically in both modes.

Existing FontAwesome fields and upgradesLink to this section

adopt-icon-picker leaves faIconPickerField imports and calls unchanged. The compatibility wrapper remains exported from @systhemaui/payload/fields and @systhemaui/payload/fields/iconPicker/fontAwesome. The latter exports only faIconPickerField, so importing iconPickerField from it fails.

A bare rename also changes behavior. The wrapper supplies the full FA Solid and Brands SVG map, merges caller options, and defaults to name: 'icon', a translated label, required: true, and returns: 'value'. The generic field defaults to name: 'iconPicker', no label or required flag, and returns: 'hybrid'.

A generic picker with no icons or packs is not empty by default. It inherits the plugin's pack descriptors, whose defaults include FA Solid and Brands, Material Symbols, and emoji. Plugin configuration can change that selection. icons on the field is a legacy SVG map; pack configuration belongs in packs, or in the plugin-level icons option. icons: { fontAwesome: true } is not a valid field-level replacement for the wrapper.

Keep the wrapper when preserving the existing icon catalog and storage behavior. Use iconPickerField for new fields, or migrate explicitly after choosing packs, field defaults, storage mode, and how callers' custom icon maps should merge.

The codemod still wraps raw .icon, .iconBefore, and .iconAfter member accesses in custom renderers. These names are a heuristic, not a cross-file trace to a CMS field. Ambiguous expressions receive conditional advice. Calls to local functions whose first parameter uses IconDefinition imported from a @fortawesome/* package are left alone without warnings. Aliased type imports, arrow functions, and additional optional or default parameters are supported. Such helpers already process local FA definitions into markup.

The repair-fa-icon-picker-import codemod in the 1.7 upgrade repairs an earlier unsafe rename at the FontAwesome subpath. It restores faIconPickerField and its bound references while preserving explicit aliases. A conflicting local name is handled with an import alias. This repair is separate from the 1.6 codemod, which is not selected when upgrading from 1.6 stable or later 1.7 canaries.

Calls to iconPickerField imported from the fields barrel are reported without modification. That valid export could be intentional. Check version control and restore the original FA import and call only if an earlier upgrade renamed them. See the 1.7 recovery notes for examples and rendering-warning limitations.

Emoji packsLink to this section

Two emoji packs are available, both enabled by default:

Apple emoji (apple-emoji)Link to this section

Renders icons as Apple-styled PNG emoji, server-resolved from a cached digest. Storage shape: {"id":"apple-emoji:1F44B"}.

icons: {
  appleEmoji: { skinTone: 'medium-dark' }, // default: 'default' (no modifier)
}

skinTone accepts 'default' | 'light' | 'medium-light' | 'medium' | 'medium-dark' | 'dark' and applies retroactively to all stored Apple emoji renders. Family / multi-person emojis ignore the configured tone and render canonically.

To disable Apple emoji entirely (removes ~15 MB of Apple artwork from the npm tarball, eliminates the legal exposure of bundling Apple's copyrighted designs):

icons: { appleEmoji: false }

See NOTICE.md for Apple's licensing terms.

Native emoji (native-emoji)Link to this section

Renders icons via the visitor's system emoji font (Apple Color Emoji on Apple OSes, Segoe UI Emoji on Windows, Noto Color Emoji on Android / Linux). Storage shape: {"id":"native-emoji:1F44B"}. Bundle cost: ~200 KB catalog only, no PNG payloads.

icons: {
  nativeEmoji: { skinTone: 'medium-dark' },
}

Picker UXLink to this section

Both packs share a single "Emoji" tab in the icon picker. A checkbox above the search input — "Use system rendering" — lets editors switch per-pick between Apple and native rendering. The grid shows the categorized scroll (organized by Apple's 9 native categories) when not searching; flat results when searching.

Linkable iconsLink to this section

The inline Icon block — both standalone in any Lexical region and when nested inside a Stack block — supports an optional link. Editors set it from the Icon block's "Link Settings" section in the Edit Icon modal, the same shape as the linkable Image / Video media blocks. Icons placed inside a Stack expose the same Link Settings and render through the same <Icon.a> variant.

FieldTypeNotes
linkType'none' | 'custom' | 'internal'Defaults to 'none'. Hides the rest of the link fields when 'none'.
urltextShown when linkType === 'custom'.
referencerelationship → pagesShown when linkType === 'internal'. Resolves to /<page-slug> server-side.
newTabcheckboxShown when the link is populated. Adds target="_blank" rel="noopener noreferrer".

When a link resolves at render time, the built-in Lexical converter renders <Icon.a> (the Next-aware variant from @systhemaui/next uses next/link for prefetching + smooth-scroll for # anchors). The icon picks up a built-in hover affordance sourced from the colorSystem.icon.hover.* token bucket — both the foreground colour and (with hasBackground) the background, border, shadow, and inner-icon colour shift on hover. Non-linked icons in the same Lexical document keep their flat appearance.

Token shape (1.6.0)Link to this section

colorSystem.icon.* flips from a flat shape to nested {normal, hover}:

Before

icon.{color, hasBackgroundColor, hasBackgroundIcon, hasBackgroundBorder, hasBackgroundShadow}

After

icon.normal.{color, hasBackgroundColor, hasBackgroundIcon, hasBackgroundBorder, hasBackgroundShadow}
icon.hover.{color, hasBackgroundColor, hasBackgroundIcon, hasBackgroundBorder, hasBackgroundShadow}

Hover values reference existing *-hover foundations ({foundations.decorative-hover}, {foundations.primary-bg-hover}, {foundations.primary-text-hover}) — projects with customised hover foundations inherit those choices automatically.

The CLI ships an icon-tokens migration (5 renames + 5 adds) that runs on systhema upgrade — existing customisations of the five base values are preserved (renames), and the parallel hover bucket is added. No manual token-file editing required.

CSS specificityLink to this section

packages/core/src/css/icon.ts is the first file to adopt :where()-wrapped selectors. Every rule has specificity (0,0,0):

:where(.icon)                                      // (0,0,0)
:where(.icon.icon-has-background)                  // (0,0,0)
:where(.icon:is(a):hover)                          // (0,0,0)
:where(.icon.icon-has-background:is(a):hover)      // (0,0,0)

Tailwind utility overrides win cleanly: bg-red-500 (0,1,0) beats the base, hover:bg-red-500 (0,2,0) beats the hover.

Custom inline-block convertersLink to this section

If you maintain a custom Lexical block that wraps an icon, mirror the built-in pattern. The link-resolution helper itself is currently module-private (packages/payload/src/lexical/convertLexicalNodesToJSX/converters/blocks/shared.ts); copy the small four-state helper into your project until it's promoted to a public export:

import { Icon } from '@systhemaui/next'
import { resolveIconForRender } from '@systhemaui/payload/fields/iconPicker/resolveIconForRender'
import type { Page } from '@/payload-types'

type LinkFields = {
  linkType?: 'none' | 'custom' | 'internal'
  url?: string
  reference?: Page | number | null
  newTab?: boolean
}

function resolveLink(fields: LinkFields) {
  const { linkType, url, reference, newTab } = fields
  if (!linkType || linkType === 'none') return null
  const target = newTab ? ('_blank' as const) : undefined
  const rel = newTab ? ('noopener noreferrer' as const) : undefined

  if (linkType === 'internal' && reference && typeof reference === 'object' && 'fullPath' in reference) {
    const fullPath = (reference as Page).fullPath
    return typeof fullPath === 'string' ? { href: fullPath || '/', target, rel } : null
  }
  if (linkType === 'custom' && url) return { href: url, target, rel }
  return null
}

export const myIconBlockConverter: CustomBlockConverter = ({ node }) => {
  const { icon, hasBackground } = node.fields
  const link = resolveLink(node.fields)
  const html = resolveIconForRender(icon).svg

  return link ? (
    <Icon.a
      hasBackground={!!hasBackground}
      href={link.href}
      target={link.target}
      rel={link.rel}
      dangerouslySetInnerHTML={{ __html: html }}
    />
  ) : (
    <Icon hasBackground={!!hasBackground} dangerouslySetInnerHTML={{ __html: html }} />
  )
}

The same helper covers the linkable Image / Video media blocks too — three call sites is the threshold, so broadening it beyond the previous resolveMediaLink name landed in 1.6.0.

FAQLink to this section

Does Material Symbols support the Grade axis? Not currently. The static SVG packages we depend on (@material-symbols/svg-{weight} from npm) only expose style and fill axes. Grade is only available on the variable font — supporting it would require generating digests from the variable font directly. Tracked as a future enhancement.

Why are 7 weights bundled instead of a "build-time pick what you need"? The MS digests live in dist/ so any consumer project can switch variants by changing the plugin option without rebuilding @systhemaui/payload. All 42 digests (3 styles × 7 weights × 2 fills) total ~140 MB on disk, but consumers only fetch the active one at runtime — admin and frontend bundles stay small.