{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "emoji-picker",
  "title": "Emoji Picker",
  "description": "Searchable emoji grid with categories, skin tones, recently used emoji and arrow-key navigation. Emoji data loads on demand.",
  "dependencies": [
    "emojibase-data",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "input",
    "@drift-ui/use-controllable-state"
  ],
  "files": [
    {
      "path": "registry/default/ui/emoji-picker.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AppleIcon,\n  ClockIcon,\n  FlagIcon,\n  HandIcon,\n  HashIcon,\n  LightbulbIcon,\n  PawPrintIcon,\n  PlaneIcon,\n  SmileIcon,\n  TrophyIcon,\n  type LucideIcon,\n} from \"lucide-react\"\n\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport {\n  addRecent,\n  buildSections,\n  layoutSections,\n  loadEmojis,\n  moveCell,\n  readPreferences,\n  RECENT_SECTION_ID,\n  searchEmojis,\n  withSkinTone,\n  writePreferences,\n  type Emoji,\n  type EmojiCell,\n  type SkinTone,\n} from \"@/registry/default/lib/emoji-picker\"\nimport { cn } from \"@/registry/default/lib/utils\"\nimport { Input } from \"@/registry/default/ui/input\"\n\nconst COLUMNS = 8\n\nconst SECTION_ICONS: Record<string, LucideIcon> = {\n  [RECENT_SECTION_ID]: ClockIcon,\n  \"0\": SmileIcon,\n  \"1\": HandIcon,\n  \"3\": PawPrintIcon,\n  \"4\": AppleIcon,\n  \"5\": PlaneIcon,\n  \"6\": TrophyIcon,\n  \"7\": LightbulbIcon,\n  \"8\": HashIcon,\n  \"9\": FlagIcon,\n}\n\nconst SKIN_TONES: { tone: SkinTone; emoji: string; label: string }[] = [\n  { tone: 0, emoji: \"✋\", label: \"Default\" },\n  { tone: 1, emoji: \"✋🏻\", label: \"Light\" },\n  { tone: 2, emoji: \"✋🏼\", label: \"Medium-light\" },\n  { tone: 3, emoji: \"✋🏽\", label: \"Medium\" },\n  { tone: 4, emoji: \"✋🏾\", label: \"Medium-dark\" },\n  { tone: 5, emoji: \"✋🏿\", label: \"Dark\" },\n]\n\nconst EmojiRow = React.memo(function EmojiRow({\n  emojis,\n  row,\n  tabColumn,\n  skinTone,\n}: {\n  emojis: Emoji[]\n  row: number\n  tabColumn: number | null\n  skinTone: SkinTone\n}) {\n  return (\n    <div\n      role=\"row\"\n      className=\"grid grid-cols-8 [contain-intrinsic-size:auto_2.25rem] [content-visibility:auto]\"\n    >\n      {emojis.map((emoji, column) => (\n        <div key={emoji.hexcode} role=\"gridcell\">\n          <button\n            type=\"button\"\n            data-row={row}\n            data-column={column}\n            tabIndex={column === tabColumn ? 0 : -1}\n            aria-label={emoji.label}\n            title={emoji.label}\n            className=\"flex aspect-square w-full items-center justify-center rounded-md text-xl outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset\"\n          >\n            {withSkinTone(emoji, skinTone)}\n          </button>\n        </div>\n      ))}\n    </div>\n  )\n})\n\nfunction EmojiPicker({\n  onSelect,\n  skinTone,\n  defaultSkinTone = 0,\n  onSkinToneChange,\n  className,\n  \"aria-label\": ariaLabel = \"Emoji picker\",\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  onSelect?: (emoji: string) => void\n  skinTone?: SkinTone\n  defaultSkinTone?: SkinTone\n  onSkinToneChange?: (skinTone: SkinTone) => void\n}) {\n  const id = React.useId()\n  const gridRef = React.useRef<HTMLDivElement>(null)\n  const searchRef = React.useRef<HTMLInputElement>(null)\n  const [library, setLibrary] = React.useState<{\n    emojis: Emoji[]\n    recent: string[]\n  } | null>(null)\n  const [hasFailed, setHasFailed] = React.useState(false)\n  const [storedSkinTone, setStoredSkinTone] = React.useState<SkinTone>()\n  const [query, setQuery] = React.useState(\"\")\n  const [activeCell, setActiveCell] = React.useState<EmojiCell>({\n    row: 0,\n    column: 0,\n  })\n  const { value: tone, setValue: setTone } = useControllableState({\n    value: skinTone ?? storedSkinTone,\n    defaultValue: defaultSkinTone,\n    onChange: (next: SkinTone) => {\n      setStoredSkinTone(next)\n      writePreferences({ ...readPreferences(), skinTone: next })\n      onSkinToneChange?.(next)\n    },\n  })\n\n  React.useEffect(() => {\n    let isCurrent = true\n    loadEmojis()\n      .then((emojis) => {\n        if (!isCurrent) return\n        const preferences = readPreferences()\n        setLibrary({ emojis, recent: preferences.recent })\n        setStoredSkinTone(preferences.skinTone)\n      })\n      .catch(() => {\n        if (isCurrent) setHasFailed(true)\n      })\n    return () => {\n      isCurrent = false\n    }\n  }, [])\n\n  const needle = query.trim()\n  const layout = React.useMemo(() => {\n    if (!library) return layoutSections([], COLUMNS)\n    const sections = needle\n      ? [\n          {\n            id: \"results\",\n            label: \"Search results\",\n            emojis: searchEmojis(library.emojis, needle),\n          },\n        ]\n      : buildSections(library.emojis, library.recent)\n    return layoutSections(sections, COLUMNS)\n  }, [library, needle])\n\n  const resultCount = needle ? (layout.sections[0]?.emojis.length ?? 0) : null\n  const tabCell = layout.rows[activeCell.row]?.[activeCell.column]\n    ? activeCell\n    : { row: 0, column: 0 }\n\n  function focusCell(cell: EmojiCell) {\n    setActiveCell(cell)\n    gridRef.current\n      ?.querySelector<HTMLElement>(\n        `[data-row=\"${cell.row}\"][data-column=\"${cell.column}\"]`\n      )\n      ?.focus()\n  }\n\n  function pick(emoji: Emoji) {\n    const preferences = readPreferences()\n    writePreferences({\n      ...preferences,\n      recent: addRecent(preferences.recent, emoji.hexcode),\n    })\n    onSelect?.(withSkinTone(emoji, tone))\n  }\n\n  function cellOf(target: EventTarget) {\n    const button = (target as HTMLElement).closest<HTMLElement>(\"[data-row]\")\n    return button\n      ? {\n          row: Number(button.dataset.row),\n          column: Number(button.dataset.column),\n        }\n      : null\n  }\n\n  function handleGridKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {\n    const cell = cellOf(event.target)\n    if (!cell) return\n    const next = moveCell(\n      layout.rows.map((row) => row.length),\n      layout.sections.map((section) => section.firstRow),\n      cell,\n      event.key,\n      event.ctrlKey || event.metaKey\n    )\n    if (!next) return\n    event.preventDefault()\n    if (event.key === \"ArrowUp\" && cell.row === 0) searchRef.current?.focus()\n    else focusCell(next)\n  }\n\n  function handleSearchKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault()\n      focusCell(tabCell)\n    }\n    if (event.key === \"Enter\" && needle && layout.rows[0]) {\n      event.preventDefault()\n      pick(layout.rows[0][0])\n    }\n  }\n\n  function scrollToSection(sectionId: string) {\n    const grid = gridRef.current\n    const section = document.getElementById(`${id}-${sectionId}`)\n    if (grid && section) grid.scrollTop = section.offsetTop\n  }\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={ariaLabel}\n      data-slot=\"emoji-picker\"\n      className={cn(\n        \"flex h-96 w-80 flex-col gap-2 rounded-lg border bg-popover p-2 text-popover-foreground\",\n        className\n      )}\n      {...props}\n    >\n      <Input\n        ref={searchRef}\n        type=\"search\"\n        aria-label=\"Search emoji\"\n        placeholder=\"Search emoji\"\n        value={query}\n        onChange={(event) => {\n          setQuery(event.target.value)\n          setActiveCell({ row: 0, column: 0 })\n          if (gridRef.current) gridRef.current.scrollTop = 0\n        }}\n        onKeyDown={handleSearchKeyDown}\n        className=\"h-8\"\n      />\n      {!needle && layout.sections.length > 0 && (\n        <div aria-hidden className=\"flex justify-between\">\n          {layout.sections.map((section) => {\n            const Icon = SECTION_ICONS[section.id]\n            return (\n              <button\n                key={section.id}\n                type=\"button\"\n                tabIndex={-1}\n                title={section.label}\n                onMouseDown={(event) => event.preventDefault()}\n                onClick={() => scrollToSection(section.id)}\n                className=\"flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground [&_svg]:size-4\"\n              >\n                <Icon />\n              </button>\n            )\n          })}\n        </div>\n      )}\n      {layout.rows.length === 0 ? (\n        <p className=\"min-h-0 flex-1 p-2 text-sm text-muted-foreground\">\n          {hasFailed\n            ? \"Emoji failed to load\"\n            : library\n              ? \"No emoji found\"\n              : \"Loading emoji\"}\n        </p>\n      ) : (\n        <div\n          ref={gridRef}\n          role=\"grid\"\n          aria-label=\"Emoji\"\n          aria-colcount={COLUMNS}\n          onKeyDown={handleGridKeyDown}\n          onFocus={(event) => {\n            const cell = cellOf(event.target)\n            if (cell) setActiveCell(cell)\n          }}\n          onClick={(event) => {\n            const cell = cellOf(event.target)\n            if (cell) pick(layout.rows[cell.row][cell.column])\n          }}\n          className=\"relative min-h-0 flex-1 overflow-y-auto\"\n        >\n          {layout.sections.map((section) => (\n            <div\n              key={section.id}\n              id={`${id}-${section.id}`}\n              role=\"rowgroup\"\n              aria-labelledby={`${id}-${section.id}-label`}\n            >\n              <div\n                id={`${id}-${section.id}-label`}\n                role=\"presentation\"\n                className=\"sticky top-0 z-10 bg-popover py-1 text-xs font-medium text-muted-foreground\"\n              >\n                {section.label}\n              </div>\n              {layout.rows\n                .slice(section.firstRow, section.firstRow + section.rowCount)\n                .map((emojis, offset) => {\n                  const row = section.firstRow + offset\n                  return (\n                    <EmojiRow\n                      key={row}\n                      emojis={emojis}\n                      row={row}\n                      tabColumn={row === tabCell.row ? tabCell.column : null}\n                      skinTone={tone}\n                    />\n                  )\n                })}\n            </div>\n          ))}\n        </div>\n      )}\n      <fieldset className=\"flex items-center justify-between\">\n        <legend className=\"sr-only\">Skin tone</legend>\n        {SKIN_TONES.map((option) => (\n          <label\n            key={option.tone}\n            title={`${option.label} skin tone`}\n            className=\"relative flex size-7 cursor-pointer items-center justify-center rounded-md text-base has-checked:bg-accent has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50\"\n          >\n            <input\n              type=\"radio\"\n              name={`${id}-skin-tone`}\n              className=\"sr-only\"\n              checked={tone === option.tone}\n              onChange={() => setTone(option.tone)}\n            />\n            <span aria-hidden>{option.emoji}</span>\n            <span className=\"sr-only\">{option.label}</span>\n          </label>\n        ))}\n      </fieldset>\n      <span role=\"status\" className=\"sr-only\">\n        {resultCount === null\n          ? \"\"\n          : `${resultCount} ${resultCount === 1 ? \"result\" : \"results\"}`}\n      </span>\n    </div>\n  )\n}\n\nexport { EmojiPicker, type SkinTone }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/emoji-picker.ts",
      "content": "export type SkinTone = 0 | 1 | 2 | 3 | 4 | 5\n\nexport type Emoji = {\n  hexcode: string\n  emoji: string\n  label: string\n  tags: string[]\n  group: number\n  order: number\n  version: number\n  skins?: { emoji: string; tone?: number | number[] }[]\n}\n\nexport type EmojiSection = { id: string; label: string; emojis: Emoji[] }\n\nexport type EmojiCell = { row: number; column: number }\n\nexport type EmojiPreferences = { recent: string[]; skinTone?: SkinTone }\n\nexport const RECENT_SECTION_ID = \"recent\"\n\nconst EMOJI_GROUPS = [\n  { id: 0, label: \"Smileys & emotion\" },\n  { id: 1, label: \"People & body\" },\n  { id: 3, label: \"Animals & nature\" },\n  { id: 4, label: \"Food & drink\" },\n  { id: 5, label: \"Travel & places\" },\n  { id: 6, label: \"Activities\" },\n  { id: 7, label: \"Objects\" },\n  { id: 8, label: \"Symbols\" },\n  { id: 9, label: \"Flags\" },\n]\n\nconst WIDELY_SUPPORTED_EMOJI_VERSION = 15\nconst MAX_RECENT = 16\nconst STORAGE_KEY = \"drift-ui:emoji-picker\"\n\nlet emojiRequest: Promise<Emoji[]> | undefined\n\nfunction isPickable(emoji: Emoji) {\n  return (\n    EMOJI_GROUPS.some((group) => group.id === emoji.group) &&\n    emoji.version <= WIDELY_SUPPORTED_EMOJI_VERSION\n  )\n}\n\nexport function loadEmojis() {\n  emojiRequest ??= import(\"emojibase-data/en/data.json\")\n    .then(({ default: data }) =>\n      (data as unknown as Emoji[])\n        .filter(isPickable)\n        .sort((first, second) => first.order - second.order)\n    )\n    .catch((error) => {\n      emojiRequest = undefined\n      throw error\n    })\n  return emojiRequest\n}\n\nexport function searchEmojis(emojis: Emoji[], query: string) {\n  const needle = query.trim().toLowerCase()\n  if (!needle) return emojis\n  return emojis.filter(\n    (emoji) =>\n      emoji.label.toLowerCase().includes(needle) ||\n      emoji.tags.some((tag) => tag.startsWith(needle))\n  )\n}\n\nexport function withSkinTone(emoji: Emoji, skinTone: SkinTone) {\n  return (\n    emoji.skins?.find((skin) => skin.tone === skinTone)?.emoji ?? emoji.emoji\n  )\n}\n\nexport function buildSections(emojis: Emoji[], recent: string[]) {\n  const recentEmojis = recent.flatMap(\n    (hexcode) => emojis.find((emoji) => emoji.hexcode === hexcode) ?? []\n  )\n  const sections: EmojiSection[] = EMOJI_GROUPS.map((group) => ({\n    id: String(group.id),\n    label: group.label,\n    emojis: emojis.filter((emoji) => emoji.group === group.id),\n  }))\n\n  return recentEmojis.length > 0\n    ? [\n        {\n          id: RECENT_SECTION_ID,\n          label: \"Recently used\",\n          emojis: recentEmojis,\n        },\n        ...sections,\n      ]\n    : sections\n}\n\nexport function layoutSections(sections: EmojiSection[], columns: number) {\n  const rows: Emoji[][] = []\n  const laidOut = sections\n    .filter((section) => section.emojis.length > 0)\n    .map((section) => {\n      const firstRow = rows.length\n      for (let index = 0; index < section.emojis.length; index += columns) {\n        rows.push(section.emojis.slice(index, index + columns))\n      }\n      return { ...section, firstRow, rowCount: rows.length - firstRow }\n    })\n\n  return { rows, sections: laidOut }\n}\n\nexport function moveCell(\n  rowLengths: number[],\n  sectionStarts: number[],\n  { row, column }: EmojiCell,\n  key: string,\n  toGridEdge: boolean\n): EmojiCell | null {\n  const lastRow = rowLengths.length - 1\n  const lastColumn = (target: number) => rowLengths[target] - 1\n  const sameColumnIn = (target: number) => ({\n    row: target,\n    column: Math.min(column, lastColumn(target)),\n  })\n\n  switch (key) {\n    case \"ArrowRight\":\n      if (column < lastColumn(row)) return { row, column: column + 1 }\n      return row < lastRow ? { row: row + 1, column: 0 } : { row, column }\n    case \"ArrowLeft\":\n      if (column > 0) return { row, column: column - 1 }\n      return row > 0\n        ? { row: row - 1, column: lastColumn(row - 1) }\n        : { row, column }\n    case \"ArrowDown\":\n      return sameColumnIn(Math.min(row + 1, lastRow))\n    case \"ArrowUp\":\n      return sameColumnIn(Math.max(row - 1, 0))\n    case \"Home\":\n      return toGridEdge ? { row: 0, column: 0 } : { row, column: 0 }\n    case \"End\":\n      return toGridEdge\n        ? { row: lastRow, column: lastColumn(lastRow) }\n        : { row, column: lastColumn(row) }\n    case \"PageDown\": {\n      const next = sectionStarts.find((start) => start > row)\n      return next === undefined\n        ? { row: lastRow, column: lastColumn(lastRow) }\n        : { row: next, column: 0 }\n    }\n    case \"PageUp\":\n      return {\n        row: sectionStarts.findLast((start) => start < row) ?? 0,\n        column: 0,\n      }\n    default:\n      return null\n  }\n}\n\nexport function addRecent(recent: string[], hexcode: string) {\n  return [hexcode, ...recent.filter((item) => item !== hexcode)].slice(\n    0,\n    MAX_RECENT\n  )\n}\n\nexport function readPreferences(): EmojiPreferences {\n  try {\n    const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? \"{}\")\n    return {\n      recent: Array.isArray(stored.recent) ? stored.recent : [],\n      skinTone: [0, 1, 2, 3, 4, 5].includes(stored.skinTone)\n        ? stored.skinTone\n        : undefined,\n    }\n  } catch {\n    return { recent: [] }\n  }\n}\n\nexport function writePreferences(preferences: EmojiPreferences) {\n  try {\n    localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences))\n  } catch {\n    return\n  }\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}