Emoji Picker
Panel and hook for choosing an emoji, with a form-field container and composable triggers for toolbars and reactions.
Pick one emoji from a searchable panel with categories, skin tones, and optional recents. The product is useEmojiPicker + EmojiPickerPanel — not a single trigger shape. Use EmojiPicker when the emoji is the field value: a compact swatch trigger under the label (like a color or icon picker), not a full-width input. Compose the same panel behind any trigger when the emoji goes into a textarea, reaction row, or toolbar icon.
Emojibase data loads from a CDN on first open. Pass emojibaseUrl to self-host the dataset. Built-in chrome strings use I18nProvider (registry item fui-i18n).
When to Use
- Form fields — status icon, category emoji, mood, or any single-emoji value on create/edit screens.
- Chat and comments — toolbar button that inserts an emoji at the textarea caret (the value lives on the text field, not the picker).
- Reactions and toolbars — preset emoji shortcuts plus a full picker, or an icon button that shows the chosen emoji on its face.
For Formily forms, use the connected EmojiPicker from Form (Formily) (Plus) — recipe A only.
Choose a Pattern
| Pattern | Value lives on | Container / trigger |
|---|---|---|
| Form field | EmojiPicker value | EmojiPicker — compact EmojiPickerTrigger swatch |
| Text insert | Textarea / Input text | Any Button + useEmojiInsert |
| Toolbar / reaction | Your state (reaction, icon, …) | EmojiPickerIconTrigger or any element via PopoverTrigger asChild |
Interactions
| Event | Default behavior |
|---|---|
| Trigger click | Open panel (Popover) |
| Emoji click | onChange(emoji); close when dismissOnSelect is true |
Clear (when allowClear) | onChange(undefined) |
| Search focus on open | When autoFocus is true |
| Skin tone change | Updates picker-wide tone via Frimousse |
| Escape / outside click | Close panel; onBlur on field container |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/emoji-picker.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/emoji-picker.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/emoji-picker.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/emoji-picker.jsonOr with a namespace: npx shadcn@latest add @f-ui/emoji-picker.
The CLI pulls button, label, popover, frimousse, lucide-react, and the fui-i18n bundle.
Usage
Form field
import { EmojiPicker } from "@/components/f-ui/emoji-picker/emoji-picker";
<EmojiPicker
label="Status icon"
value={icon}
onChange={setIcon}
/>;Insert at caret
import { useEmojiInsert, useEmojiPicker } from "@/components/f-ui/emoji-picker/emoji-picker";
const insertEmoji = useEmojiInsert(textareaRef, { value: text, onChange: setText });
const picker = useEmojiPicker({
onChange: (emoji) => emoji && insertEmoji(emoji),
});Call onPointerDown={(e) => e.preventDefault()} on the toolbar trigger so the textarea keeps focus when the panel opens.
Examples
Basic
Default form field: label + compact bordered swatch (size-9). Empty state shows a Smile icon inside the box — not placeholder text in a long input. Helper copy belongs in description, not inside the trigger.
Pick one emoji stored as the field value.
Field value: —
"use client";
import { useState } from "react";
import { EmojiPicker } from "@/components/f-ui/emoji-picker/emoji-picker";
export function EmojiPickerDemo() {
const [icon, setIcon] = useState<string | null>(null);
return (
<div className="max-w-sm space-y-3">
<EmojiPicker
label="Status icon"
description="Pick one emoji stored as the field value."
value={icon}
onChange={(emoji) => setIcon(emoji ?? null)}
/>
<p className="text-muted-foreground text-xs">
Field value:{" "}
<span className="text-foreground font-medium">{icon ?? "—"}</span>
</p>
</div>
);
}Disabled
Read-only field state — trigger does not open.
"use client";
import { EmojiPicker } from "@/components/f-ui/emoji-picker/emoji-picker";
export function EmojiPickerDisabledDemo() {
return (
<div className="max-w-sm">
<EmojiPicker
label="Status icon"
value="🚀"
disabled
onChange={() => undefined}
/>
</div>
);
}Allow Clear
Hover the trigger to reveal clear when a value is set.
Field value: 😀
"use client";
import { useState } from "react";
import { EmojiPicker } from "@/components/f-ui/emoji-picker/emoji-picker";
export function EmojiPickerClearableDemo() {
const [icon, setIcon] = useState<string | null>("😀");
return (
<div className="max-w-sm space-y-3">
<EmojiPicker
label="Status icon"
value={icon}
allowClear
onChange={(emoji) => setIcon(emoji ?? null)}
/>
<p className="text-muted-foreground text-xs">
Field value:{" "}
<span className="text-foreground font-medium">{icon ?? "—"}</span>
</p>
</div>
);
}Validation Status
Pair isInvalid with errorMessage for form validation feedback.
Choose an icon for this status.
"use client";
import { EmojiPicker } from "@/components/f-ui/emoji-picker/emoji-picker";
export function EmojiPickerInvalidDemo() {
return (
<div className="max-w-sm">
<EmojiPicker
label="Status icon"
isInvalid
errorMessage="Choose an icon for this status."
onChange={() => undefined}
/>
</div>
);
}Frequently Used
enableRecents persists picks in localStorage and shows them above the category list.
"use client";
import { useState } from "react";
import { EmojiPicker } from "@/components/f-ui/emoji-picker/emoji-picker";
export function EmojiPickerRecentsDemo() {
const [icon, setIcon] = useState<string | null>(null);
return (
<div className="max-w-sm">
<EmojiPicker
label="Status icon"
value={icon}
enableRecents
onChange={(emoji) => setIcon(emoji ?? null)}
/>
</div>
);
}Chat Composer
Insert into message text at the caret. The picker is toolbar chrome; useEmojiInsert owns the splice.
Comment
"use client";
import { Smile } from "lucide-react";
import { useRef, useState } from "react";
import {
EmojiPickerContent,
EmojiPickerFooter,
EmojiPickerPanel,
EmojiPickerPopover,
EmojiPickerSearch,
useEmojiInsert,
useEmojiPicker,
} from "@/components/f-ui/emoji-picker/emoji-picker";
import { EmojiPickerSkinToneButton } from "@/components/f-ui/emoji-picker/emoji-picker-parts/footer";
import { Button } from "@/components/ui/button";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function EmojiPickerChatComposerDemo() {
const [message, setMessage] = useState("Looks good to me ");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const insertEmoji = useEmojiInsert(textareaRef, {
value: message,
onChange: setMessage,
});
const picker = useEmojiPicker({
dismissOnSelect: true,
onChange: (emoji) => {
if (emoji) insertEmoji(emoji);
},
});
return (
<div className="max-w-md space-y-2">
<p className="text-sm font-medium">Comment</p>
<div className="overflow-hidden rounded-lg border bg-card">
<Textarea
ref={textareaRef}
value={message}
onChange={(event) => setMessage(event.currentTarget.value)}
placeholder="Add a comment…"
rows={3}
className="min-h-24 resize-none rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0"
/>
<div className="flex items-center justify-between border-t px-2 py-1.5">
<TooltipProvider>
<Tooltip>
<Popover
modal={false}
open={picker.open}
onOpenChange={picker.onOpenChange}
>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
aria-label={picker.t("emojiPicker.open")}
onPointerDown={(event) => event.preventDefault()}
>
<Smile className="size-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{picker.t("emojiPicker.open")}</TooltipContent>
<EmojiPickerPopover side="top" align="start">
<EmojiPickerPanel {...picker.panelProps}>
<EmojiPickerSearch
placeholder={picker.t("emojiPicker.searchPlaceholder")}
clearLabel={picker.t("emojiPicker.clear")}
autoFocus={picker.autoFocus}
trailingSlot={
<EmojiPickerSkinToneButton
skinToneLabel={picker.t("emojiPicker.skinTone")}
/>
}
/>
<EmojiPickerContent
loadingLabel={picker.t("emojiPicker.loading")}
emptyLabel={picker.t("emojiPicker.empty")}
/>
<EmojiPickerFooter
previewPlaceholder={picker.t(
"emojiPicker.previewPlaceholder",
)}
skinToneLabel={picker.t("emojiPicker.skinTone")}
/>
</EmojiPickerPanel>
</EmojiPickerPopover>
</Popover>
</Tooltip>
</TooltipProvider>
<span className="text-muted-foreground text-xs">Inserts at caret</span>
</div>
</div>
</div>
);
}Icon Trigger
Square trigger for toolbars — shows the emoji on the button when set, Smile when empty.
Button icon: 🎨
"use client";
import { useState } from "react";
import {
EmojiPickerContent,
EmojiPickerFooter,
EmojiPickerIconTrigger,
EmojiPickerPanel,
EmojiPickerPopover,
EmojiPickerSearch,
useEmojiPicker,
} from "@/components/f-ui/emoji-picker/emoji-picker";
import { EmojiPickerSkinToneButton } from "@/components/f-ui/emoji-picker/emoji-picker-parts/footer";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
export function EmojiPickerIconTriggerDemo() {
const [icon, setIcon] = useState<string | null>("🎨");
const picker = useEmojiPicker({
value: icon,
onChange: (emoji) => setIcon(emoji ?? null),
});
return (
<div className="max-w-sm space-y-3">
<div className="flex items-center gap-2 rounded-lg border bg-card px-3 py-2">
<span className="text-muted-foreground text-sm">Workspace icon</span>
<Popover modal={false} open={picker.open} onOpenChange={picker.onOpenChange}>
<PopoverTrigger asChild>
<EmojiPickerIconTrigger
value={icon}
openLabel={picker.t("emojiPicker.open")}
/>
</PopoverTrigger>
<EmojiPickerPopover align="start">
<EmojiPickerPanel {...picker.panelProps}>
<EmojiPickerSearch
placeholder={picker.t("emojiPicker.searchPlaceholder")}
clearLabel={picker.t("emojiPicker.clear")}
autoFocus={picker.autoFocus}
trailingSlot={
<EmojiPickerSkinToneButton
skinToneLabel={picker.t("emojiPicker.skinTone")}
/>
}
/>
<EmojiPickerContent
loadingLabel={picker.t("emojiPicker.loading")}
emptyLabel={picker.t("emojiPicker.empty")}
/>
<EmojiPickerFooter
previewPlaceholder={picker.t("emojiPicker.previewPlaceholder")}
skinToneLabel={picker.t("emojiPicker.skinTone")}
/>
</EmojiPickerPanel>
</EmojiPickerPopover>
</Popover>
</div>
<p className="text-muted-foreground text-xs">
Button icon:{" "}
<span className="text-foreground text-lg font-medium">{icon ?? "—"}</span>
</p>
</div>
);
}Reactions
Preset shortcuts for common reactions; + opens the full panel for anything else.
Shipped the new emoji picker docs.
Reaction: 👍
"use client";
import { Plus } from "lucide-react";
import { useState } from "react";
import {
EmojiPickerContent,
EmojiPickerFooter,
EmojiPickerPanel,
EmojiPickerPopover,
EmojiPickerSearch,
useEmojiPicker,
} from "@/components/f-ui/emoji-picker/emoji-picker";
import { EmojiPickerSkinToneButton } from "@/components/f-ui/emoji-picker/emoji-picker-parts/footer";
import { Button } from "@/components/ui/button";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const PRESET_REACTIONS = ["👍", "❤️", "😂", "🎉", "😮"];
export function EmojiPickerReactionsDemo() {
const [reaction, setReaction] = useState<string | null>("👍");
const picker = useEmojiPicker({
onChange: (emoji) => setReaction(emoji ?? null),
dismissOnSelect: true,
});
return (
<div className="max-w-sm space-y-3">
<div className="space-y-3 rounded-lg border bg-card p-3">
<p className="text-sm">Shipped the new emoji picker docs.</p>
<TooltipProvider>
<div className="flex items-center gap-1">
{PRESET_REACTIONS.map((emoji) => (
<Tooltip key={emoji}>
<TooltipTrigger asChild>
<Button
type="button"
variant={reaction === emoji ? "secondary" : "ghost"}
size="icon"
className="size-8 text-lg"
onClick={() => setReaction(emoji)}
aria-label={`React with ${emoji}`}
>
{emoji}
</Button>
</TooltipTrigger>
<TooltipContent>{`React with ${emoji}`}</TooltipContent>
</Tooltip>
))}
<Tooltip>
<Popover
modal={false}
open={picker.open}
onOpenChange={picker.onOpenChange}
>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
aria-label={picker.t("emojiPicker.open")}
>
<Plus className="size-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{picker.t("emojiPicker.open")}</TooltipContent>
<EmojiPickerPopover align="start">
<EmojiPickerPanel {...picker.panelProps}>
<EmojiPickerSearch
placeholder={picker.t("emojiPicker.searchPlaceholder")}
clearLabel={picker.t("emojiPicker.clear")}
autoFocus={picker.autoFocus}
trailingSlot={
<EmojiPickerSkinToneButton
skinToneLabel={picker.t("emojiPicker.skinTone")}
/>
}
/>
<EmojiPickerContent
loadingLabel={picker.t("emojiPicker.loading")}
emptyLabel={picker.t("emojiPicker.empty")}
/>
<EmojiPickerFooter
previewPlaceholder={picker.t(
"emojiPicker.previewPlaceholder",
)}
skinToneLabel={picker.t("emojiPicker.skinTone")}
/>
</EmojiPickerPanel>
</EmojiPickerPopover>
</Popover>
</Tooltip>
</div>
</TooltipProvider>
</div>
<p className="text-muted-foreground text-xs">
Reaction:{" "}
<span className="text-foreground text-lg font-medium">{reaction ?? "—"}</span>
</p>
</div>
);
}Custom Trigger
PopoverTrigger asChild accepts any button or link — the panel stays the same.
Any element can be the trigger
"use client";
import { Smile } from "lucide-react";
import {
EmojiPickerContent,
EmojiPickerFooter,
EmojiPickerPanel,
EmojiPickerPopover,
EmojiPickerSearch,
useEmojiPicker,
} from "@/components/f-ui/emoji-picker/emoji-picker";
import { EmojiPickerSkinToneButton } from "@/components/f-ui/emoji-picker/emoji-picker-parts/footer";
import { Button } from "@/components/ui/button";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
export function EmojiPickerCustomTriggerDemo() {
const picker = useEmojiPicker({
dismissOnSelect: true,
onChange: () => undefined,
});
return (
<div className="max-w-sm space-y-2">
<p className="text-sm font-medium">Any element can be the trigger</p>
<Popover modal={false} open={picker.open} onOpenChange={picker.onOpenChange}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="gap-2">
<Smile className="size-4" />
Add reaction
</Button>
</PopoverTrigger>
<EmojiPickerPopover align="start">
<EmojiPickerPanel {...picker.panelProps}>
<EmojiPickerSearch
placeholder={picker.t("emojiPicker.searchPlaceholder")}
clearLabel={picker.t("emojiPicker.clear")}
autoFocus={picker.autoFocus}
trailingSlot={
<EmojiPickerSkinToneButton
skinToneLabel={picker.t("emojiPicker.skinTone")}
/>
}
/>
<EmojiPickerContent
loadingLabel={picker.t("emojiPicker.loading")}
emptyLabel={picker.t("emojiPicker.empty")}
/>
<EmojiPickerFooter
previewPlaceholder={picker.t("emojiPicker.previewPlaceholder")}
skinToneLabel={picker.t("emojiPicker.skinTone")}
/>
</EmojiPickerPanel>
</EmojiPickerPopover>
</Popover>
</div>
);
}Headless Usage
useEmojiPicker is the view-model: open state, selection, i18n, and triggerProps / panelProps bags. Your field chrome can be plain HTML — the demo uses an unstyled <button> and <label>, not EmojiPickerTrigger. Wire panelProps into EmojiPickerPanel (or swap the panel for your own grid later).
value: —
"use client";
import { useState } from "react";
import {
EmojiPickerContent,
EmojiPickerFooter,
EmojiPickerPanel,
EmojiPickerPopover,
EmojiPickerSearch,
useEmojiPicker,
} from "@/components/f-ui/emoji-picker/emoji-picker";
import { EmojiPickerSkinToneButton } from "@/components/f-ui/emoji-picker/emoji-picker-parts/footer";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
export function EmojiPickerHeadlessDemo() {
const [icon, setIcon] = useState<string | null>(null);
const picker = useEmojiPicker({
value: icon,
onChange: (emoji) => setIcon(emoji ?? null),
});
const { openLabel, placeholder } = picker.triggerProps;
return (
<Popover modal={false} open={picker.open} onOpenChange={picker.onOpenChange}>
<div>
<label htmlFor="headless-emoji-picker">Status icon</label>
<div>
<PopoverTrigger asChild>
<button id="headless-emoji-picker" type="button" aria-label={openLabel}>
{icon ?? placeholder}
</button>
</PopoverTrigger>
</div>
<p>value: {icon ?? "—"}</p>
</div>
<EmojiPickerPopover align="start">
<EmojiPickerPanel {...picker.panelProps}>
<EmojiPickerSearch
placeholder={picker.t("emojiPicker.searchPlaceholder")}
clearLabel={picker.t("emojiPicker.clear")}
autoFocus={picker.autoFocus}
trailingSlot={
<EmojiPickerSkinToneButton skinToneLabel={picker.t("emojiPicker.skinTone")} />
}
/>
<EmojiPickerContent
loadingLabel={picker.t("emojiPicker.loading")}
emptyLabel={picker.t("emojiPicker.empty")}
/>
<EmojiPickerFooter
previewPlaceholder={picker.t("emojiPicker.previewPlaceholder")}
skinToneLabel={picker.t("emojiPicker.skinTone")}
/>
</EmojiPickerPanel>
</EmojiPickerPopover>
</Popover>
);
}Composition
EmojiPicker (form field)
├── Label (optional)
├── Popover
│ ├── PopoverTrigger → EmojiPickerTrigger
│ └── EmojiPickerPopover → EmojiPickerPanel
│ ├── EmojiPickerSearch (+ skin tone on narrow widths)
│ ├── EmojiPickerRecents (enableRecents)
│ ├── EmojiPickerContent
│ └── EmojiPickerFooter
├── Description (optional)
└── Error message (optional)
Headless (insert / toolbar / reactions)
├── Popover + PopoverTrigger asChild → your trigger
└── EmojiPickerPopover → EmojiPickerPanel (same panel tree)| Trigger part | Use when |
|---|---|
EmojiPickerTrigger | Compact bordered swatch for labeled form fields |
EmojiPickerIconTrigger | Ghost icon button for toolbars |
Your Button | Custom chrome (asChild on PopoverTrigger) |
API Reference
Props
| Prop | Type | Default |
|---|---|---|
value | string | null | — |
onChange | (emoji: string | undefined) => void | — |
onBlur | () => void | — |
locale | string | i18n/provider locale |
skinTone | SkinTone | — |
columns | number | 9 |
enableRecents | boolean | false |
storageKey | string | "f-ui-emoji-recents" |
maxRecents | number | 18 |
dismissOnSelect | boolean | true |
allowClear | boolean | false |
autoFocus | boolean | true |
emojibaseUrl | string | Frimousse CDN default |
emojiVersion | number | — |
disabled | boolean | false |
isInvalid | boolean | false |
placeholder | string | localized emojiPicker.placeholder |
label | ReactNode | — |
description | ReactNode | — |
errorMessage | ReactNode | — |
className | string | — |
classNames | Partial<Record<EmojiPickerSlot, string>> | — |
popover | EmojiPickerPopoverProps | — |
t | EmojiPickerTranslateFn | built-in/provider |
Slots
| Slot | Element |
|---|---|
root | Outer wrapper |
label | Field label |
trigger | Emoji trigger button |
popover | Popover panel |
panel | Frimousse root panel |
search | Search input row |
content | Virtualized emoji viewport |
footer | Preview + skin tone row |
emoji | Individual emoji cell |
categoryHeader | Sticky category label |
description | Description text |
errorMessage | Error text |
Hook — useEmojiPicker(options)
| Option | Type |
|---|---|
value | string | null |
onChange | (emoji: string | undefined) => void |
onBlur | () => void |
locale | string |
skinTone | SkinTone |
columns | number |
enableRecents | boolean |
storageKey | string |
maxRecents | number |
dismissOnSelect | boolean |
allowClear | boolean |
autoFocus | boolean |
emojibaseUrl | string |
emojiVersion | number |
disabled | boolean |
isInvalid | boolean |
placeholder | string |
t | EmojiPickerTranslateFn |
| Return | Type |
|---|---|
open / onOpenChange | popover open state |
t | resolved translator |
frimousseLocale | mapped locale tag for Frimousse |
triggerProps | spread on EmojiPickerTrigger |
panelProps | spread on EmojiPickerPanel |
recents | recent emoji strings |
onRecentSelect | select handler for recents strip |
onClear | clear committed value |
autoFocus | focus search on open |
Hook — useEmojiInsert(ref, options)
| Option | Type |
|---|---|
ref | RefObject<HTMLTextAreaElement | HTMLInputElement | null> |
value | string |
onChange | (next: string) => void |
| Return | Type |
|---|---|
| insert | (emoji: string) => void |