f-ui
Components

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

PatternValue lives onContainer / trigger
Form fieldEmojiPicker valueEmojiPicker — compact EmojiPickerTrigger swatch
Text insertTextarea / Input textAny Button + useEmojiInsert
Toolbar / reactionYour state (reaction, icon, …)EmojiPickerIconTrigger or any element via PopoverTrigger asChild

Interactions

EventDefault behavior
Trigger clickOpen panel (Popover)
Emoji clickonChange(emoji); close when dismissOnSelect is true
Clear (when allowClear)onChange(undefined)
Search focus on openWhen autoFocus is true
Skin tone changeUpdates picker-wide tone via Frimousse
Escape / outside clickClose panel; onBlur on field container

Installing

pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/emoji-picker.json
npx shadcn@latest add https://ui.isaacfei.com/r/emoji-picker.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/emoji-picker.json
bun x shadcn@latest add https://ui.isaacfei.com/r/emoji-picker.json

Or 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

Inserts at caret
"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.

Workspace icon

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 partUse when
EmojiPickerTriggerCompact bordered swatch for labeled form fields
EmojiPickerIconTriggerGhost icon button for toolbars
Your ButtonCustom chrome (asChild on PopoverTrigger)

API Reference

Props

PropTypeDefault
valuestring | null
onChange(emoji: string | undefined) => void
onBlur() => void
localestringi18n/provider locale
skinToneSkinTone
columnsnumber9
enableRecentsbooleanfalse
storageKeystring"f-ui-emoji-recents"
maxRecentsnumber18
dismissOnSelectbooleantrue
allowClearbooleanfalse
autoFocusbooleantrue
emojibaseUrlstringFrimousse CDN default
emojiVersionnumber
disabledbooleanfalse
isInvalidbooleanfalse
placeholderstringlocalized emojiPicker.placeholder
labelReactNode
descriptionReactNode
errorMessageReactNode
classNamestring
classNamesPartial<Record<EmojiPickerSlot, string>>
popoverEmojiPickerPopoverProps
tEmojiPickerTranslateFnbuilt-in/provider

Slots

SlotElement
rootOuter wrapper
labelField label
triggerEmoji trigger button
popoverPopover panel
panelFrimousse root panel
searchSearch input row
contentVirtualized emoji viewport
footerPreview + skin tone row
emojiIndividual emoji cell
categoryHeaderSticky category label
descriptionDescription text
errorMessageError text

Hook — useEmojiPicker(options)

OptionType
valuestring | null
onChange(emoji: string | undefined) => void
onBlur() => void
localestring
skinToneSkinTone
columnsnumber
enableRecentsboolean
storageKeystring
maxRecentsnumber
dismissOnSelectboolean
allowClearboolean
autoFocusboolean
emojibaseUrlstring
emojiVersionnumber
disabledboolean
isInvalidboolean
placeholderstring
tEmojiPickerTranslateFn
ReturnType
open / onOpenChangepopover open state
tresolved translator
frimousseLocalemapped locale tag for Frimousse
triggerPropsspread on EmojiPickerTrigger
panelPropsspread on EmojiPickerPanel
recentsrecent emoji strings
onRecentSelectselect handler for recents strip
onClearclear committed value
autoFocusfocus search on open

Hook — useEmojiInsert(ref, options)

OptionType
refRefObject<HTMLTextAreaElement | HTMLInputElement | null>
valuestring
onChange(next: string) => void
ReturnType
insert(emoji: string) => void

On this page