---
title: "Icon picker"
description: "Icon packs, storage format, emoji, linkable icons and custom converters."
requested_language: hu
language: en
translation_notice: "This page isn't translated yet"
url: https://docs.systhema.app/hu/next/payload/editor/icons
version: unreleased (main)
docs_index: https://docs.systhema.app/hu/next/llms.txt
---
> This page isn't translated yet. Showing English.


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](#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 options

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

```ts
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' },
})
```

### `icons`

| Key               | Type                                                                                                                              | Default                                           |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| `fontAwesome`     | `false \| { styles?: Array<'solid' \| 'regular' \| 'brands'> }`                                                                   | `{ styles: ['solid', 'brands'] }`                 |
| `materialSymbols` | `false \| { style?: 'outlined' \| 'rounded' \| 'sharp'; weight?: 100 \| 200 \| 300 \| 400 \| 500 \| 600 \| 700; fill?: boolean }` | `{ style: 'outlined', weight: 400, fill: false }` |
| `appleEmoji`      | `false \| { skinTone?: 'default' \| 'light' \| 'medium-light' \| 'medium' \| 'medium-dark' \| 'dark' }`                           | `{ skinTone: 'default' }`                         |
| `nativeEmoji`     | `false \| { skinTone?: 'default' \| 'light' \| 'medium-light' \| 'medium' \| 'medium-dark' \| 'dark' }`                           | `{ skinTone: 'default' }`                         |
| `custom`          | `Array<{ 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` defaults

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 format

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

| Pack                 | Stored shape                                               | Why                                                                                                                                                           |
| -------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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>` string                                  | Continues 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 work

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.5

> [!NOTE]
> Changed in [v1.6.0](../../release-notes/v1.6.0.md): icon picks are stored as identifiers, and custom converters resolve them with `resolveIconForRender`.

### Action required: custom Lexical converters

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:

```diff
+ 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.

> [!NOTE]
> **It is also client-safe**, which matters if you use `livePreview.mode: 'client'` — your converters then run in the browser. The resolver imports no Node builtin and no digest: the two packs that need a render-time lookup (Material Symbols, Apple emoji) read an in-memory cache that only the server fills, and everything else (custom packs, Font Awesome, legacy raw-SVG strings) already carries its markup in the stored value, so it resolves identically on both sides. In the browser those two packs resolve to nothing on their own — see [Rendering icons in the browser](#rendering-icons-in-the-browser) for how that gap is closed.
>
> From a `'use client'` file, import it from the barrel-free client entry:
>
> ```tsx
> 'use client'
> import { resolveIconHtml } from '@systhemaui/payload/next/client'
> ```
>
> `resolveIconHtml` is the same resolver returning renderable HTML directly — it prefers `.html` (Apple emoji `<img>`, native emoji `<span>`) and falls back to `.svg`, so a converter does not have to know which pack an icon came from. Reading `.svg` alone silently drops the emoji packs.
>
> What is **not** client-safe is the icon-picker FIELD half: `iconPickerField` from `@systhemaui/payload/fields` builds an admin schema and reaches admin-only code. If one module holds both a custom block's `fields` and its `converter`, split the converter into its own module before registering it with `registerSysthemaClientBlocks`.

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 browser

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:

```tsx
'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`)

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:

```ts
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 upgrades

`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](../../release-notes/v1.7.0-breaking-changes.md#21-fontawesome-picker-fields-keep-faiconpickerfield-189)
for examples and rendering-warning limitations.

## Emoji packs

Two emoji packs are available, both enabled by default:

### Apple emoji (`apple-emoji`)

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

```ts
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):

```ts
icons: { appleEmoji: false }
```

See `NOTICE.md` for Apple's licensing terms.

### Native emoji (`native-emoji`)

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.

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

### Picker UX

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 icons

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.

| Field       | Type                               | Notes                                                                               |
| ----------- | ---------------------------------- | ----------------------------------------------------------------------------------- |
| `linkType`  | `'none' \| 'custom' \| 'internal'` | Defaults to `'none'`. Hides the rest of the link fields when `'none'`.              |
| `url`       | `text`                             | Shown when `linkType === 'custom'`.                                                 |
| `reference` | relationship → `pages`             | Shown when `linkType === 'internal'`. Resolves to `/<page-slug>` server-side.       |
| `newTab`    | `checkbox`                         | Shown 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>`](https://docs.systhema.app/hu/next/components/icon.md) (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)

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

**Before**

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

**After**

```text
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 specificity

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

```ts
: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 converters

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:

```tsx
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.

## FAQ

**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.
