Skip to content

EmojiPicker

Form

Searchable Unicode reactions with categories, skin tones, and an optional popover trigger.

Inline reaction pickerComposer popoverSearch and categoriesSix skin-tone choices
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Use the inline EmojiPicker for reaction surfaces or EmojiPickerButton for a compact composer action. onEmojiSelect returns the selected Unicode string, including an applicable skin-tone modifier. The default collection contains 36 curated entries; it is deliberately a small editable dataset, not the complete Unicode emoji standard. Supply your own named/localized entries for broader coverage, with skinTone true only for supported base gestures. The picker does not mutate input text or send messages: the caller owns insertion, caret restoration, and submission. The Chat page demonstrates inserting into the current selection.

Import
import { EmojiPicker } from "@/components/ui/emoji-picker";

Shipped as editable source in src/components/ui/emoji-picker.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/emoji-picker.tsx
"use client";

import { useId, useRef, useState, type RefObject } from "react";
import { Smile } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Input } from "@dashboardpack/core/components/ui/input";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@dashboardpack/core/components/ui/popover";
import { cn } from "@dashboardpack/core/lib/utils";
import { useDocumentDirection } from "@/lib/use-document-direction";
export interface EmojiEntry {
  emoji: string;
  name: string;
  category: string;
  keywords?: string;
  skinTone?: boolean;
}
// A small editable Unicode collection; applications can supply a larger, localized dataset.
export const defaultEmojis: EmojiEntry[] = [
  ["😀", "Grinning face", "Faces", "happy smile"],
  ["😊", "Smiling face", "Faces", "happy blush"],
  ["😂", "Tears of joy", "Faces", "laugh funny"],
  ["🥹", "Holding back tears", "Faces", "thankful"],
  ["😍", "Heart eyes", "Faces", "love"],
  ["🤔", "Thinking face", "Faces", "question"],
  ["😎", "Sunglasses", "Faces", "cool"],
  ["🥳", "Party face", "Faces", "celebrate"],
  ["😴", "Sleeping face", "Faces", "tired"],
  ["😢", "Crying face", "Faces", "sad"],
  ["🙃", "Upside-down face", "Faces", "ironic"],
  ["🤩", "Star eyes", "Faces", "excited"],
  ["👍", "Thumbs up", "Gestures", "yes agree", true],
  ["👎", "Thumbs down", "Gestures", "no disagree", true],
  ["👋", "Waving hand", "Gestures", "hello goodbye", true],
  ["👏", "Clapping hands", "Gestures", "well done", true],
  ["🙌", "Raised hands", "Gestures", "hooray", true],
  ["🙏", "Folded hands", "Gestures", "thanks please", true],
  ["💪", "Flexed biceps", "Gestures", "strong", true],
  ["🤞", "Crossed fingers", "Gestures", "luck", true],
  ["❤️", "Red heart", "Symbols", "love"],
  ["💚", "Green heart", "Symbols", "love"],
  ["✨", "Sparkles", "Symbols", "new shine"],
  ["✅", "Check mark", "Symbols", "done approved"],
  ["⚠️", "Warning", "Symbols", "attention"],
  ["💡", "Light bulb", "Symbols", "idea"],
  ["🔥", "Fire", "Symbols", "hot"],
  ["🎉", "Party popper", "Symbols", "celebrate"],
  ["🚀", "Rocket", "Work", "launch ship"],
  ["🎯", "Target", "Work", "goal"],
  ["📅", "Calendar", "Work", "date"],
  ["📌", "Pin", "Work", "save"],
  ["💻", "Laptop", "Work", "computer"],
  ["📊", "Chart", "Work", "analytics"],
  ["☕", "Coffee", "Work", "break"],
  ["🌱", "Seedling", "Work", "grow"],
].map(([emoji, name, category, keywords, skinTone]) => ({
  emoji: String(emoji),
  name: String(name),
  category: String(category),
  keywords: String(keywords),
  skinTone: Boolean(skinTone),
}));
const tones = ["", "🏻", "🏼", "🏽", "🏾", "🏿"];
const toneNames = [
  "Default",
  "Light",
  "Medium-light",
  "Medium",
  "Medium-dark",
  "Dark",
];
export function emojiWithTone(entry: EmojiEntry, tone: number) {
  return (
    entry.emoji +
    (entry.skinTone
      ? tones[Math.min(5, Math.max(0, Math.trunc(tone) || 0))]
      : "")
  );
}
export interface EmojiPickerProps {
  label?: string;
  onEmojiSelect: (emoji: string) => void;
  entries?: EmojiEntry[];
  disabled?: boolean;
  className?: string;
}
export function EmojiPicker({
  label = "Choose an emoji",
  onEmojiSelect,
  entries = defaultEmojis,
  disabled,
  className,
}: EmojiPickerProps) {
  const id = useId();
  const grid = useRef<HTMLDivElement>(null);
  const direction = useDocumentDirection();
  const [query, setQuery] = useState("");
  const [category, setCategory] = useState("All");
  const [tone, setTone] = useState(0);
  const categories = [
    "All",
    ...new Set(entries.map((entry) => entry.category)),
  ];
  const results = entries.filter(
    (entry) =>
      (category === "All" || category === entry.category) &&
      `${entry.name} ${entry.keywords ?? ""} ${entry.emoji}`
        .toLowerCase()
        .includes(query.trim().toLowerCase()),
  );
  return (
    <section
      aria-label={label}
      className={cn(
        "w-full min-w-0 space-y-3 rounded-xl border bg-card p-3",
        className,
      )}
    >
      <div className="flex items-center justify-between gap-2">
        <h3 className="text-sm font-semibold">{label}</h3>
        <span className="text-xs text-muted-foreground">Unicode</span>
      </div>
      <label htmlFor={`${id}-search`} className="sr-only">
        Search emojis
      </label>
      <Input
        id={`${id}-search`}
        placeholder="Search a feeling or an idea"
        value={query}
        disabled={disabled}
        onChange={(event) => setQuery(event.target.value)}
      />
      <div
        aria-label="Emoji categories"
        role="group"
        className="flex flex-wrap gap-1"
      >
        {categories.map((item) => (
          <button
            type="button"
            key={item}
            aria-pressed={category === item}
            disabled={disabled}
            onClick={() => setCategory(item)}
            className={cn(
              "rounded-md px-2 py-1 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
              category === item
                ? "bg-primary text-primary-foreground"
                : "text-muted-foreground hover:bg-muted",
            )}
          >
            {item}
          </button>
        ))}
      </div>
      <div
        ref={grid}
        role="group"
        aria-label="Emoji results"
        className="grid max-h-48 grid-cols-8 gap-1 overflow-auto"
        onKeyDown={(event) => {
          const buttons = [
            ...(grid.current?.querySelectorAll<HTMLButtonElement>("button") ??
              []),
          ];
          const current = buttons.indexOf(
            document.activeElement as HTMLButtonElement,
          );
          if (current < 0) return;
          let next = current;
          if (event.key === "ArrowRight") next += direction === "rtl" ? -1 : 1;
          else if (event.key === "ArrowLeft")
            next += direction === "rtl" ? 1 : -1;
          else if (event.key === "ArrowDown") next += 8;
          else if (event.key === "ArrowUp") next -= 8;
          else if (event.key === "Home") next = 0;
          else if (event.key === "End") next = buttons.length - 1;
          else return;
          event.preventDefault();
          buttons[Math.max(0, Math.min(buttons.length - 1, next))]?.focus();
        }}
      >
        {results.map((entry, index) => (
          <button
            key={`${entry.name}-${entry.emoji}`}
            type="button"
            disabled={disabled}
            tabIndex={index === 0 ? 0 : -1}
            aria-label={
              entry.name +
              (entry.skinTone && tone ? `, ${toneNames[tone]} skin tone` : "")
            }
            title={entry.name}
            className="flex aspect-square items-center justify-center rounded-md text-2xl hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
            onClick={() => onEmojiSelect(emojiWithTone(entry, tone))}
          >
            <span aria-hidden>{emojiWithTone(entry, tone)}</span>
          </button>
        ))}
      </div>
      {!results.length && (
        <p className="py-3 text-center text-sm text-muted-foreground">
          No emojis found. Try another word.
        </p>
      )}
      <div className="flex items-center justify-between gap-2 border-t pt-3">
        <p role="status" className="text-xs text-muted-foreground">
          {results.length} emojis · Use arrow keys
        </p>
        <label className="flex items-center gap-2 text-xs">
          Skin tone
          <select
            className="min-w-0 rounded-md border bg-background px-1 py-1"
            value={tone}
            disabled={disabled}
            onChange={(event) => setTone(Number(event.target.value))}
          >
            {toneNames.map((name, index) => (
              <option key={name} value={index}>
                {name}
              </option>
            ))}
          </select>
        </label>
      </div>
    </section>
  );
}
export function EmojiPickerButton({
  onEmojiSelect,
  disabled,
  returnFocusRef,
}: Pick<EmojiPickerProps, "onEmojiSelect" | "disabled"> & {
  returnFocusRef?: RefObject<HTMLElement | null>;
}) {
  const [open, setOpen] = useState(false);
  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button
          type="button"
          variant="ghost"
          size="icon-sm"
          disabled={disabled}
          aria-label="Insert emoji"
        >
          <Smile className="size-4" />
        </Button>
      </PopoverTrigger>
      <PopoverContent
        onCloseAutoFocus={(event) => {
          if (returnFocusRef?.current) {
            event.preventDefault();
            returnFocusRef.current.focus();
          }
        }}
        align="end"
        className="w-80 max-w-[calc(100vw-2rem)] border-0 bg-transparent p-0 shadow-none"
      >
        <EmojiPicker
          onEmojiSelect={(emoji) => {
            setOpen(false);
            onEmojiSelect(emoji);
          }}
        />
      </PopoverContent>
    </Popover>
  );
}
Required helper: src/lib/use-document-direction.ts

Copy this helper alongside the component. It keeps keyboard navigation aligned with the page direction and supports live RTL changes.

"use client";

import { useSyncExternalStore } from "react";

function subscribe(notify: () => void) {
  const observer = new MutationObserver(notify);
  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ["dir"],
  });
  return () => observer.disconnect();
}

function snapshot(): "ltr" | "rtl" {
  return document.documentElement.dir === "rtl" ? "rtl" : "ltr";
}

/** Keep headless controls aligned with the document, including live RTL changes. */
export function useDocumentDirection() {
  return useSyncExternalStore(subscribe, snapshot, () => "ltr" as const);
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

EmojiPicker props
PropType / DefaultDescription
onEmojiSelect(emoji: string) => void

Default: required

Receive a Unicode string without performing any sending or persistence.
entriesEmojiEntry[]

Default: 36 curated entries

Editable emoji, name, category, optional keywords, and skinTone support.
labelstring

Default: Choose an emoji

Inline section heading and accessible name.
disabled / classNameboolean / string

Default: false / —

Disable interactions or extend inline layout.
EmojiPickerButtoncomponent

Default: —

Compact popover wrapper accepting onEmojiSelect, disabled, and optional returnFocusRef for composer focus restoration.

Build with these too