{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "emoji-picker",
  "title": "Emoji Picker",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An emoji picker that loads its data on first use: search by name or word, jump between groups, pick a skin tone, recent emoji, a real ARIA grid with arrow keys, and any language from Emojibase.",
  "dependencies": [
    "emojibase-data@^18",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/i18n.json",
    "https://ui.ballmac.com/r/direction.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/emoji-picker.tsx",
      "content": "// Ballmac UI: Emoji Picker. https://ui.ballmac.com/components/emoji-picker\n// Based on Emojibase (MIT, Copyright (c) 2017-2019 Miles Johnson), which supplies the emoji, their names and search words in many languages.\n\"use client\"\n\nimport * as React from \"react\"\nimport { Clock, Dumbbell, Flag, Hand, Lightbulb, Leaf, Plane, Search, Smile, Hash, UtensilsCrossed, X } from \"lucide-react\"\n\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { StateView } from \"@/components/ballmac/state-view\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\nimport { useLocale, useMessages } from \"@/lib/ballmac/i18n\"\nimport { cn } from \"@/lib/utils\"\n\n/** One emoji as the picker uses it. */\ntype Emoji = {\n  /** The character: \"👋\". */\n  emoji: string\n  /** Its name in the picker's language: \"waving hand\". */\n  label: string\n  hexcode: string\n  /** Search words. */\n  tags?: string[]\n  /** The same emoji in the five skin tones, light to dark, for the ones that have them. */\n  skins?: { emoji: string; label: string; hexcode: string }[]\n}\n\ntype EmojiGroup = { key: string; label: string; emojis: Emoji[] }\n\n/** The emoji and the names of their groups, in the language they are shown. `loadEmojis` returns this. */\ntype EmojiData = { groups: EmojiGroup[] }\n\ntype EmojiSelection = { emoji: string; label: string; hexcode: string; skinTone: number }\n\ntype EmojiPickerProps = Omit<React.ComponentProps<\"div\">, \"onSelect\" | \"children\"> & {\n  /** Called when an emoji is chosen. */\n  onSelect: (emoji: EmojiSelection) => void\n  /** Returns the emoji in your language. The default loads English, and only when the picker is first shown. See the docs for other languages. */\n  loadEmojis?: () => Promise<EmojiData>\n  /** Emoji across. The picker is as wide as it needs to be for this many. */\n  columns?: number\n  /** Show the people's emoji in this tone, 0 for the default yellow to 5 for the darkest. Remembered unless you set it. */\n  skinTone?: number\n  onSkinToneChange?: (tone: number) => void\n  /** Keep the emoji people use most. Off in browsers that block storage. */\n  recent?: boolean\n  /** Key under which recent emoji are kept, so two pickers can have their own. */\n  storageKey?: string\n  /** Height of the scrolling list. */\n  height?: number\n  /** Focus the search box when the picker mounts. */\n  autoFocus?: boolean\n  /** Leave out emoji this device cannot draw, which would show as empty boxes. On by default. */\n  hideUnsupported?: boolean\n}\n\nconst TONES = [\"\", \"🏻\", \"🏼\", \"🏽\", \"🏾\", \"🏿\"]\nconst RECENT_MAX = 24\n\nconst GROUP_ICONS: Record<string, React.ComponentType<{ className?: string; \"aria-hidden\"?: boolean | \"true\" }>> = {\n  \"smileys-emotion\": Smile,\n  \"people-body\": Hand,\n  \"animals-nature\": Leaf,\n  \"food-drink\": UtensilsCrossed,\n  \"travel-places\": Plane,\n  activities: Dumbbell,\n  objects: Lightbulb,\n  symbols: Hash,\n  flags: Flag,\n}\n\ntype RawEmoji = { group?: number; hexcode: string; label: string; tags?: string[]; unicode: string; skins?: { hexcode: string; label: string; unicode: string }[] }\ntype RawGroup = { key: string; message: string; order: number }\n\n/** The English emoji, loaded the first time they are needed so the data stays out of the page's first download. */\nasync function loadEnglish(): Promise<EmojiData> {\n  const [compact, messages] = await Promise.all([import(\"emojibase-data/en/compact.json\"), import(\"emojibase-data/en/messages.json\")])\n  return fromEmojibase(compact.default, messages.default.groups)\n}\n\n/** Turns Emojibase's compact list and its group names into what the picker uses. Use it in your own `loadEmojis` for another language. */\nexport function fromEmojibase(compact: ReadonlyArray<object>, groupMessages: ReadonlyArray<object>): EmojiData {\n  const list = compact as RawEmoji[]\n  const groups = (groupMessages as RawGroup[])\n    .filter((g) => g.key !== \"component\")\n    .sort((a, b) => a.order - b.order)\n    .map((g) => ({\n      key: g.key,\n      label: g.message.replace(/(^|[\\s&-])\\p{L}/gu, (c) => c.toUpperCase()),\n      emojis: list\n        .filter((e) => e.group === g.order)\n        .map<Emoji>((e) => ({ emoji: e.unicode, label: e.label, hexcode: e.hexcode, tags: e.tags, skins: e.skins?.map((s) => ({ emoji: s.unicode, label: s.label, hexcode: s.hexcode })) })),\n    }))\n  return { groups }\n}\n\n/**\n * Says whether the device's emoji font can draw an emoji. A missing one is drawn as an empty box or nothing, and a sequence that falls apart is\n * drawn as its parts, which makes it wider. Both are told from a real one by drawing on a canvas. Without a canvas everything counts as supported.\n */\nfunction makeSupportTest(): (emoji: string) => boolean {\n  if (typeof document === \"undefined\") return () => true\n  const canvas = document.createElement(\"canvas\")\n  canvas.width = canvas.height = 40\n  const ctx = canvas.getContext(\"2d\", { willReadFrequently: true })\n  if (!ctx) return () => true\n  ctx.font = \"28px sans-serif\"\n  ctx.textBaseline = \"top\"\n  const ink = (text: string) => {\n    ctx.clearRect(0, 0, 40, 40)\n    ctx.fillText(text, 2, 2)\n    const px = ctx.getImageData(0, 0, 40, 40).data\n    let sum = 0\n    for (let i = 3; i < px.length; i += 4) sum += px[i]!\n    return sum\n  }\n  const missing = ink(\"\\u{10FFFF}\")\n  const one = ctx.measureText(\"😀\").width\n  const cache = new Map<string, boolean>()\n  return (emoji) => {\n    const hit = cache.get(emoji)\n    if (hit !== undefined) return hit\n    const drawn = ink(emoji)\n    const joined = /\\u200d|[\\u{1F1E6}-\\u{1F1FF}]|\\u20e3/u.test(emoji)\n    const ok = drawn > 0 && drawn !== missing && !(joined && ctx.measureText(emoji).width > one * 1.45)\n    cache.set(emoji, ok)\n    return ok\n  }\n}\n\ntype Load = { status: \"loading\" } | { status: \"ready\"; data: EmojiData } | { status: \"error\" }\n\nfunction useEmojiData(loader: () => Promise<EmojiData>) {\n  const [load, setLoad] = React.useState<Load>({ status: \"loading\" })\n  const [attempt, setAttempt] = React.useState(0)\n  const ref = React.useRef(loader)\n  ref.current = loader\n  React.useEffect(() => {\n    let live = true\n    setLoad({ status: \"loading\" })\n    ref.current().then(\n      (data) => live && setLoad({ status: \"ready\", data }),\n      () => live && setLoad({ status: \"error\" })\n    )\n    return () => {\n      live = false\n    }\n  }, [attempt])\n  return { load, retry: () => setAttempt((n) => n + 1) }\n}\n\nfunction readStored<T>(key: string, fallback: T): T {\n  try {\n    const raw = localStorage.getItem(key)\n    return raw ? (JSON.parse(raw) as T) : fallback\n  } catch {\n    return fallback\n  }\n}\nfunction writeStored(key: string, value: unknown) {\n  try {\n    localStorage.setItem(key, JSON.stringify(value))\n  } catch {\n    /* storage can be blocked */\n  }\n}\n\nconst pick = (e: Emoji, tone: number) => (tone > 0 && e.skins?.[tone - 1] ? e.skins[tone - 1]! : e)\n\n/**\n * An emoji picker that loads its emoji when it is first shown. People search by name or word, jump between groups, pick a skin tone and find the\n * emoji they used last. The grid is a real ARIA grid: arrow keys move across it, Enter picks, and the search box is always one Tab away.\n */\nfunction EmojiPicker({\n  onSelect,\n  loadEmojis = loadEnglish,\n  columns = 8,\n  skinTone: toneProp,\n  onSkinToneChange,\n  recent = true,\n  storageKey = \"ballmac-emoji-recent\",\n  height = 288,\n  autoFocus = false,\n  hideUnsupported = true,\n  className,\n  ...props\n}: EmojiPickerProps) {\n  const msg = useMessages()\n  const dir = useDirection()\n  const locale = useLocale()\n  const id = React.useId()\n  const { load, retry } = useEmojiData(loadEmojis)\n  const [query, setQuery] = React.useState(\"\")\n  const [tone, setTone] = React.useState(0)\n  const [recentList, setRecentList] = React.useState<{ hexcode: string }[]>([])\n  const [focusAt, setFocusAt] = React.useState<{ s: number; i: number }>({ s: 0, i: 0 })\n  const [hover, setHover] = React.useState<Emoji | null>(null)\n  const [spoken, setSpoken] = React.useState(\"\")\n  const scroller = React.useRef<HTMLDivElement>(null)\n  const wantFocus = React.useRef(false)\n\n  // what the person chose before comes from storage after mount, so the server and the browser draw the same first frame\n  React.useEffect(() => {\n    if (recent) setRecentList(readStored(storageKey, []))\n    if (toneProp === undefined) setTone(readStored<number>(`${storageKey}-tone`, 0))\n  }, [recent, storageKey, toneProp])\n  const skin = toneProp ?? tone\n  const setSkin = (t: number) => {\n    if (toneProp === undefined) setTone(t)\n    writeStored(`${storageKey}-tone`, t)\n    onSkinToneChange?.(t)\n  }\n\n  const loaded = load.status === \"ready\" ? load.data : null\n  // emoji the device cannot draw are left out, once, when the data arrives; this runs in the browser only, so the server draws nothing different\n  const data = React.useMemo(() => {\n    if (!loaded || !hideUnsupported) return loaded\n    const ok = makeSupportTest()\n    return { groups: loaded.groups.map((g) => ({ ...g, emojis: g.emojis.filter((e) => ok(e.emoji)) })).filter((g) => g.emojis.length) }\n  }, [loaded, hideUnsupported])\n  const byHex = React.useMemo(() => {\n    const map = new Map<string, Emoji>()\n    data?.groups.forEach((g) => g.emojis.forEach((e) => map.set(e.hexcode, e)))\n    return map\n  }, [data])\n\n  // the sections on show: recent, then each group, or one list of search results\n  const q = query.trim().toLowerCase()\n  const sections = React.useMemo(() => {\n    if (!data) return []\n    if (q) {\n      const found = data.groups.flatMap((g) => g.emojis).filter((e) => e.label.toLowerCase().includes(q) || e.tags?.some((t) => t.toLowerCase().includes(q)))\n      return [{ key: \"search\", label: msg(\"emoji-picker.results\", \"Search results\"), icon: Search, emojis: found }]\n    }\n    const rec = recentList.map((r) => byHex.get(r.hexcode)).filter((e): e is Emoji => !!e)\n    return [\n      ...(recent && rec.length ? [{ key: \"recent\", label: msg(\"emoji-picker.recent\", \"Recently used\"), icon: Clock, emojis: rec }] : []),\n      ...data.groups.map((g) => ({ key: g.key, label: g.label, icon: GROUP_ICONS[g.key] ?? Smile, emojis: g.emojis })),\n    ]\n  }, [data, q, recent, recentList, byHex, msg])\n\n  const choose = (e: Emoji) => {\n    const used = pick(e, skin)\n    onSelect({ emoji: used.emoji, label: used.label, hexcode: e.hexcode, skinTone: e.skins ? skin : 0 })\n    setSpoken(msg(\"emoji-picker.selected\", \"Selected {name}\", { name: used.label }))\n    if (recent) {\n      const next = [{ hexcode: e.hexcode }, ...recentList.filter((r) => r.hexcode !== e.hexcode)].slice(0, RECENT_MAX)\n      setRecentList(next)\n      writeStored(storageKey, next)\n    }\n  }\n\n  // ----- keyboard: arrow keys across and down the grid, wrapping into the next section\n  const move = (s: number, i: number) => {\n    wantFocus.current = true\n    setFocusAt({ s, i })\n  }\n  React.useLayoutEffect(() => {\n    if (!wantFocus.current) return\n    wantFocus.current = false\n    scroller.current?.querySelector<HTMLElement>(`[data-s=\"${focusAt.s}\"][data-i=\"${focusAt.i}\"]`)?.focus()\n  }, [focusAt, sections])\n  const onGridKey = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const t = (e.target as HTMLElement).closest<HTMLElement>(\"[data-s][data-i]\")\n    if (!t) return\n    const s = Number(t.dataset.s)\n    const i = Number(t.dataset.i)\n    const len = (n: number) => sections[n]?.emojis.length ?? 0\n    const back = dir === \"rtl\" ? \"ArrowRight\" : \"ArrowLeft\"\n    const fwd = dir === \"rtl\" ? \"ArrowLeft\" : \"ArrowRight\"\n    const col = i % columns\n    let to: [number, number] | null = null\n    if (e.key === fwd) to = i + 1 < len(s) ? [s, i + 1] : s + 1 < sections.length ? [s + 1, 0] : null\n    else if (e.key === back) to = i > 0 ? [s, i - 1] : s > 0 ? [s - 1, len(s - 1) - 1] : null\n    else if (e.key === \"ArrowDown\") {\n      if (i + columns < len(s)) to = [s, i + columns]\n      else if (s + 1 < sections.length) to = [s + 1, Math.min(col, len(s + 1) - 1)]\n      else if (Math.floor(i / columns) < Math.floor((len(s) - 1) / columns)) to = [s, len(s) - 1]\n    } else if (e.key === \"ArrowUp\") {\n      if (i - columns >= 0) to = [s, i - columns]\n      else if (s > 0) {\n        const last = len(s - 1) - 1\n        const lastRowStart = Math.floor(last / columns) * columns\n        to = [s - 1, Math.min(lastRowStart + col, last)]\n      }\n    } else if (e.key === \"Home\") to = e.ctrlKey ? [0, 0] : [s, i - col]\n    else if (e.key === \"End\") to = e.ctrlKey ? [sections.length - 1, len(sections.length - 1) - 1] : [s, Math.min(i - col + columns - 1, len(s) - 1)]\n    else if (e.key === \"PageDown\") to = s + 1 < sections.length ? [s + 1, 0] : null\n    else if (e.key === \"PageUp\") to = i > 0 ? [s, 0] : s > 0 ? [s - 1, 0] : null\n    if (to) {\n      e.preventDefault()\n      move(to[0], to[1])\n    }\n  }\n\n  const jump = (key: string) => {\n    const el = scroller.current?.querySelector<HTMLElement>(`[data-section=\"${key}\"]`)\n    if (el && scroller.current) scroller.current.scrollTo({ top: el.offsetTop - scroller.current.offsetTop, behavior: \"auto\" })\n  }\n\n  const cell = 36\n  const width = columns * cell + 16 + 12\n  const total = sections.reduce((n, s) => n + s.emojis.length, 0)\n\n  const skeleton = (\n    <div className=\"grid gap-1 p-2\" aria-hidden=\"true\" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>\n      {Array.from({ length: columns * 6 }, (_, i) => (\n        <Skeleton key={i} className=\"size-8 rounded-md\" />\n      ))}\n    </div>\n  )\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={msg(\"emoji-picker.label\", \"Emoji picker\")}\n      data-slot=\"emoji-picker\"\n      dir={dir}\n      className={cn(\"flex flex-col overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-md\", className)}\n      style={{ width }}\n      {...props}\n    >\n      <div className=\"relative border-b p-2\">\n        <Search className=\"pointer-events-none absolute start-4 top-1/2 size-4 -translate-y-1/2 text-muted-foreground\" aria-hidden=\"true\" />\n        <input\n          type=\"search\"\n          role=\"searchbox\"\n          autoFocus={autoFocus}\n          value={query}\n          onChange={(e) => setQuery(e.target.value)}\n          onKeyDown={(e) => {\n            if (e.key === \"ArrowDown\" && total) {\n              e.preventDefault()\n              move(0, 0)\n            } else if (e.key === \"Escape\" && query) {\n              e.stopPropagation()\n              setQuery(\"\")\n            }\n          }}\n          aria-label={msg(\"emoji-picker.search\", \"Search emoji\")}\n          aria-controls={load.status === \"ready\" ? `${id}-grid` : undefined}\n          placeholder={msg(\"emoji-picker.search\", \"Search emoji\")}\n          disabled={load.status !== \"ready\"}\n          lang={locale}\n          className=\"h-9 w-full rounded-md border border-input bg-background ps-9 pe-8 text-sm shadow-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50 [&::-webkit-search-cancel-button]:hidden\"\n        />\n        {query ? (\n          <button type=\"button\" onClick={() => setQuery(\"\")} aria-label={msg(\"emoji-picker.clear\", \"Clear search\")} className=\"absolute end-3.5 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\">\n            <X className=\"size-3.5\" aria-hidden=\"true\" />\n          </button>\n        ) : null}\n      </div>\n\n      {data && !q ? (\n        <div role=\"toolbar\" aria-label={msg(\"emoji-picker.groups\", \"Emoji groups\")} aria-orientation=\"horizontal\" className=\"flex items-center justify-between gap-0.5 border-b px-2 py-1\">\n          {sections.map((s) => {\n            const Icon = s.icon\n            return (\n              <button key={s.key} type=\"button\" onClick={() => jump(s.key)} aria-label={s.label} title={s.label} className=\"flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\">\n                <Icon className=\"size-4\" aria-hidden=\"true\" />\n              </button>\n            )\n          })}\n        </div>\n      ) : null}\n\n      <StateView\n        state={load.status === \"loading\" ? \"loading\" : load.status === \"error\" ? \"error\" : \"ready\"}\n        loading={skeleton}\n        error={{ title: msg(\"emoji-picker.errorTitle\", \"Could not load the emoji\"), description: msg(\"emoji-picker.errorDescription\", \"Check your connection and try again.\") }}\n        onRetry={retry}\n        label={msg(\"emoji-picker.loading\", \"Loading emoji\")}\n        bare\n        className=\"min-h-0 flex-1\"\n      >\n        <div ref={scroller} id={`${id}-grid`} onKeyDown={onGridKey} className=\"overflow-y-auto overscroll-contain px-2 pb-2\" style={{ height }}>\n          {total === 0 ? (\n            <p role=\"status\" className=\"px-2 py-10 text-center text-sm text-muted-foreground\">\n              {msg(\"emoji-picker.none\", \"No emoji found for “{query}”\", { query: query.trim() })}\n            </p>\n          ) : null}\n          {sections.map((section, si) => {\n            if (!section.emojis.length) return null\n            const rows = Math.ceil(section.emojis.length / columns)\n            return (\n              <section key={section.key} data-section={section.key} aria-labelledby={`${id}-${section.key}`} className=\"[content-visibility:auto] [contain-intrinsic-size:auto_200px]\">\n                <h3 id={`${id}-${section.key}`} className=\"sticky top-0 z-10 -mx-2 bg-popover/95 px-3 py-1.5 text-xs font-medium text-muted-foreground backdrop-blur-sm\">\n                  {section.label}\n                </h3>\n                <div role=\"grid\" aria-label={section.label} aria-rowcount={rows} aria-colcount={columns}>\n                  {Array.from({ length: rows }, (_, r) => (\n                    <div key={r} role=\"row\" aria-rowindex={r + 1} className=\"grid\" style={{ gridTemplateColumns: `repeat(${columns}, ${cell}px)` }}>\n                      {section.emojis.slice(r * columns, r * columns + columns).map((e, c) => {\n                        const shown = pick(e, skin)\n                        const i = r * columns + c\n                        const here = focusAt.s === si && focusAt.i === i\n                        return (\n                          <div key={e.hexcode} role=\"gridcell\" aria-colindex={c + 1}>\n                            <button\n                              type=\"button\"\n                              data-s={si}\n                              data-i={i}\n                              tabIndex={here ? 0 : -1}\n                              aria-label={shown.label}\n                              onClick={() => choose(e)}\n                              onFocus={() => {\n                                setFocusAt({ s: si, i })\n                                setHover(e)\n                              }}\n                              onMouseEnter={() => setHover(e)}\n                              onMouseLeave={() => setHover(null)}\n                              className=\"flex size-9 items-center justify-center rounded-md text-2xl leading-none outline-none transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\"\n                            >\n                              <span aria-hidden=\"true\">{shown.emoji}</span>\n                            </button>\n                          </div>\n                        )\n                      })}\n                    </div>\n                  ))}\n                </div>\n              </section>\n            )\n          })}\n        </div>\n      </StateView>\n\n      <div className=\"flex h-12 items-center gap-2 border-t bg-muted/40 px-3\">\n        <span aria-hidden=\"true\" className=\"flex size-8 items-center justify-center text-2xl leading-none\">\n          {hover ? pick(hover, skin).emoji : \"🙂\"}\n        </span>\n        <span className=\"min-w-0 flex-1 truncate text-sm text-muted-foreground\">{hover ? pick(hover, skin).label : msg(\"emoji-picker.prompt\", \"Pick an emoji\")}</span>\n        <div role=\"group\" aria-label={msg(\"emoji-picker.skinTone\", \"Skin tone\")} className=\"flex shrink-0 items-center gap-0.5\">\n          {TONES.map((t, n) => (\n            <button\n              key={n}\n              type=\"button\"\n              aria-pressed={skin === n}\n              aria-label={n === 0 ? msg(\"emoji-picker.toneDefault\", \"Default skin tone\") : msg(\"emoji-picker.tone\", \"Skin tone {n}\", { n })}\n              title={n === 0 ? msg(\"emoji-picker.toneDefault\", \"Default skin tone\") : msg(\"emoji-picker.tone\", \"Skin tone {n}\", { n })}\n              onClick={() => setSkin(n)}\n              className={cn(\"flex size-6 items-center justify-center rounded-md text-sm outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\", skin === n && \"bg-accent ring-1 ring-ring/40\")}\n            >\n              <span aria-hidden=\"true\">{`✋${t}`}</span>\n            </button>\n          ))}\n        </div>\n      </div>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {spoken || (q && data ? msg(\"emoji-picker.count\", { one: \"{count} emoji found\", other: \"{count} emoji found\" }, { count: total }) : \"\")}\n      </span>\n    </div>\n  )\n}\n\nexport { EmojiPicker, type Emoji, type EmojiData, type EmojiGroup, type EmojiPickerProps, type EmojiSelection }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/emoji-picker.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "emoji",
      "picker",
      "reactions",
      "chat",
      "search",
      "skin tone",
      "grid"
    ],
    "version": "1.0.0",
    "updated": "2026-10-19",
    "tests": true,
    "ai": {
      "summary": "<EmojiPicker onSelect={(e) => insert(e.emoji)} />. It loads English emoji on first use. For another language pass loadEmojis that returns fromEmojibase(compact, groups) for that language. Wrap it in a popover for a button.",
      "whenToUse": [
        "Chat, comments and reactions where people add emoji",
        "A reaction bar's \"more\" menu",
        "Any text field that needs an emoji button"
      ],
      "whenNotToUse": [
        "A fixed handful of reactions; show buttons instead",
        "Inserting images or stickers",
        "Custom emoji sets; map them into the same EmojiData and pass loadEmojis"
      ],
      "composesWith": [
        "popover",
        "textarea",
        "mention-input",
        "rich-text-editor"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Search, group buttons, the grid (one tab stop) and the skin tones are separate stops"
        },
        {
          "keys": "Arrow keys",
          "action": "Move across the grid and into the next group; left and right swap in right-to-left"
        },
        {
          "keys": "Home / End / Ctrl+Home / Ctrl+End",
          "action": "Start and end of the row, and of the whole grid"
        },
        {
          "keys": "Enter / Space",
          "action": "Choose the emoji"
        },
        {
          "keys": "Arrow Down in search",
          "action": "Moves to the first result"
        },
        {
          "keys": "Screen readers",
          "action": "Each emoji is a button named in the picker's language; results found and the chosen emoji are announced"
        }
      ],
      "customization": [
        "columns, height, autoFocus",
        "skinTone and onSkinToneChange to hold the tone yourself",
        "recent and storageKey, or recent={false}",
        "loadEmojis for another language or your own emoji"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "emoji-picker-loading",
        "trigger": "while the emoji data loads",
        "note": "A grid of skeletons under a disabled search box, announced as loading. The data is fetched the first time the picker is shown."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "emoji-picker-error",
        "trigger": "loadEmojis rejects",
        "note": "An error with a Try again button that loads the emoji again, announced as an alert."
      }
    ],
    "source": {
      "name": "Emojibase",
      "url": "https://github.com/milesj/emojibase",
      "license": "MIT",
      "copyright": "Copyright (c) 2017-2019 Miles Johnson",
      "modified": true
    },
    "examples": [
      "emoji-picker-demo",
      "emoji-picker-popover",
      "emoji-picker-german",
      "emoji-picker-loading",
      "emoji-picker-error"
    ],
    "url": "https://ui.ballmac.com/components/emoji-picker"
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}