Emoji & Icon Picker

One picker for an emoji or a Lucide icon that stores the choice as a single short string, like emoji:🚀 or icon:rocket.

Preview

Usage

The field stores the serialized string, so a plain z.string() does the job.

import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormEmojiIcon } from '@vitnode/core/components/form/fields/emoji-icon'
import { EMOJI_ICON_MAX_LENGTH } from '@vitnode/core/lib/emoji-icon'

const formSchema = z.object({
  prefix: z.string().max(EMOJI_ICON_MAX_LENGTH).default(''),
})
<AutoForm
  formSchema={formSchema}
  fields={[
    {
      id: 'prefix',
      component: (props) => (
        <AutoFormEmojiIcon {...props} allowRemove label="Prefix" />
      ),
    },
  ]}
/>

The value

type EmojiIconValue =
  { type: 'emoji'; value: string } | { type: 'icon'; value: string }

An emoji value is the character (🚀). An icon value is a kebab-case Lucide name (shield-check), readable in the database and portable to any Lucide renderer.

For storage, serialize it to emoji:🚀 or icon:rocket. It fits a varchar(64) column.

import {
  parseEmojiIcon,
  serializeEmojiIcon,
} from '@vitnode/core/lib/emoji-icon'

serializeEmojiIcon({ type: 'icon', value: 'rocket' })
parseEmojiIcon('emoji:🚀')
parseEmojiIcon('icon:../../etc/passwd')

These return "icon:rocket", { type: 'emoji', value: '🚀' } and undefined. parseEmojiIcon is also the validator: anything that isn't exactly one emoji or one icon name comes back undefined.

Validate on the server too

The picker only produces valid values. A request body can say anything, so parse it before you write it:

prefix: serializeEmojiIcon(parseEmojiIcon(body.prefix)) || null

Rendering a stored value

EmojiIcon renders a parsed value: a glyph for an emoji, an SVG for an icon. An invalid value renders nothing.

import { EmojiIcon } from '@vitnode/core/components/ui/emoji-icon'

;<EmojiIcon value={parseEmojiIcon(role.prefix)} />

Only icon names? Use DynamicIcon. Icons rendered on the server arrive in the first HTML, and the browser never ships the whole icon library. That needs one line in getRouter(), already in the scaffold:

src/router.tsx
import { setupLucideIconSsr } from '@vitnode/core/tanstack/icons'

setupLucideIconSsr({ router })

Without it, DynamicIcon still works but loads icons in the browser after the page renders.

Both are aria-hidden, since they decorate nearby text. An icon-only control needs its own aria-label.

Role prefixes

A role's Prefix field (General tab of its edit dialog) uses this picker. The prefix shows before the role name and before every member's name wherever RoleFormat or UserFormat renders. It arrives as role.prefix (null | string), and both components parse it for you.

<UserFormat format user={user} />

Performance and keyboard

  • Only the trigger ships with your page. Emoji data loads when the popover opens, and the ~1750 icons load only when someone opens the Icon tab.
  • The icon grid is virtualized, so its size doesn't hurt scrolling.
  • The grid is one tab stop. Arrows move (up and down jump a row), Home / End go to the row's ends, Enter or Space picks.
  • The emoji picker here and in the editor toolbar share grid size, search and skin tone switch.

Props

EmojiIconPicker

Prop

Type

Other button props go to the trigger. AutoFormEmojiIcon takes the same props except value and onChange.

EmojiPicker and IconPicker

The bare panels, with no popover or trigger. Drop them into a dialog, sidebar or toolbar.

import { EmojiPicker } from '@vitnode/core/components/ui/emoji-picker'
import { IconPicker } from '@vitnode/core/components/ui/icon-picker'

;<IconPicker onSelect={setIcon} value={icon} />

Prop

Type