Emoji & Icon Picker

One picker with segmented emoji and icon modes that returns a discriminated value you can store in a single column.

Preview

Shown in front of the name of every member of this role.

Usage

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 allowRemove label="Prefix" {...props} />
      ),
    },
  ]}
/>

The value

The component speaks in a discriminated union, so you always know which mode the answer came from:

type EmojiIconValue =
  | { type: 'emoji'; value: string } // value: "🚀"
  | { type: 'icon'; value: string } // value: "rocket", a Lucide name

For storage that union collapses into one short string - emoji:🚀 or icon:rocket - which fits in a single varchar(64) column and survives a round trip through JSON without any schema gymnastics:

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

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

parseEmojiIcon is the validator as well as the reader. It returns undefined for anything that is not exactly one emoji or one Lucide icon name, so a value that came from a request body is safe to render without a second check.

Validate on the server too

The picker can only produce valid values, but a request body can say anything. Run the incoming string through parseEmojiIcon before you write it, exactly as the roles API does:

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

Rendering a stored value

EmojiIcon takes a parsed value and renders the right thing - a text glyph for an emoji, an SVG for an icon:

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

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

If you only ever deal with icon names, reach for DynamicIcon instead:

import { DynamicIcon } from '@vitnode/core/components/ui/dynamic-icon'

;<DynamicIcon className="size-5" name="shield-check" />

Both are decorative by default (aria-hidden), because they sit next to the text they illustrate. If an icon is the only content of a control, give that control its own aria-label.

Role prefixes

Every role has a Prefix field on the General tab of its edit dialog. Whatever you pick there is rendered in front of the role name and in front of the name of every member of that role, wherever RoleFormat or UserFormat appears:

<UserFormat format user={user} /> // 🚀 Ada Lovelace

The role payload carries it as role.prefix, a null | string in the stored format above. Nothing else has to change - both components parse it themselves and render nothing when it is empty.

Performance

The panel is code-split twice over. The trigger button is all that ships with your page; the emoji data and the segmented panel load when the popover opens, and the ~1750 Lucide icons load only when someone switches to the Icon tab. The icon grid is virtualized, so the size of the icon set does not show up in scroll performance.

Icon names

Names are the kebab-case ones from lucide.dev/icons: shield-check, arrow-up-right, volume-2. That is what gets stored, so a value stays readable in the database and portable to any other Lucide renderer.

Props

EmojiIconPicker

Prop

Type

EmojiPicker and IconPicker

Both halves are exported on their own if you only need one of them. They render the bare panel - no popover, no trigger - so you can drop either into a dialog, a sidebar or a toolbar:

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

;<IconPicker onSelect={(name) => console.log(name)} />

Prop

Type