Emoji & Icon Picker
One picker with segmented emoji and icon modes that returns a discriminated value you can store in a single column.
Preview
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 nameFor 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') // undefinedparseEmojiIcon 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)) || nullRendering 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 LovelaceThe 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