Emoji Picker

Searchable emoji grid with categories, skin tones, recently used emoji and arrow-key navigation. Emoji data loads on demand.

@drift-ui/emoji-pickerRegistry JSON

Loading emoji

Skin tone

Pick a reaction

On this page

Installation

The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.

API reference

EmojiPicker

An inline emoji panel with search, categories, skin tones and recently used emoji. Data loads on first mount, and recents and the skin tone persist in localStorage.

Prop
onSelect(emoji: string) => voidCalled with the chosen emoji, skin tone applied.
skinTone0 | 1 | 2 | 3 | 4 | 5Controlled skin tone. 0 is the default yellow.
defaultSkinTone0 | 1 | 2 | 3 | 4 | 5= 0Tone to use until the user picks one.
onSkinToneChange(skinTone: 0 | 1 | 2 | 3 | 4 | 5) => voidCalled when the user picks a skin tone.

Other props go to the <div>.

Source

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

import * as React from "react"
import {
  AppleIcon,
  ClockIcon,
  FlagIcon,
  HandIcon,
  HashIcon,
  LightbulbIcon,
  PawPrintIcon,
  PlaneIcon,
  SmileIcon,
  TrophyIcon,
  type LucideIcon,
} from "lucide-react"

import { useControllableState } from "@/hooks/use-controllable-state"
import {
  addRecent,
  buildSections,
  layoutSections,
  loadEmojis,
  moveCell,
  readPreferences,
  RECENT_SECTION_ID,
  searchEmojis,
  withSkinTone,
  writePreferences,
  type Emoji,
  type EmojiCell,
  type SkinTone,
} from "@/lib/emoji-picker"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"

const COLUMNS = 8

const SECTION_ICONS: Record<string, LucideIcon> = {
  [RECENT_SECTION_ID]: ClockIcon,
  "0": SmileIcon,
  "1": HandIcon,
  "3": PawPrintIcon,
  "4": AppleIcon,
  "5": PlaneIcon,
  "6": TrophyIcon,
  "7": LightbulbIcon,
  "8": HashIcon,
  "9": FlagIcon,
}

const SKIN_TONES: { tone: SkinTone; emoji: string; label: string }[] = [
  { tone: 0, emoji: "✋", label: "Default" },
  { tone: 1, emoji: "✋🏻", label: "Light" },
  { tone: 2, emoji: "✋🏼", label: "Medium-light" },
  { tone: 3, emoji: "✋🏽", label: "Medium" },
  { tone: 4, emoji: "✋🏾", label: "Medium-dark" },
  { tone: 5, emoji: "✋🏿", label: "Dark" },
]

const EmojiRow = React.memo(function EmojiRow({
  emojis,
  row,
  tabColumn,
  skinTone,
}: {
  emojis: Emoji[]
  row: number
  tabColumn: number | null
  skinTone: SkinTone
}) {
  return (
    <div
      role="row"
      className="grid grid-cols-8 [contain-intrinsic-size:auto_2.25rem] [content-visibility:auto]"
    >
      {emojis.map((emoji, column) => (
        <div key={emoji.hexcode} role="gridcell">
          <button
            type="button"
            data-row={row}
            data-column={column}
            tabIndex={column === tabColumn ? 0 : -1}
            aria-label={emoji.label}
            title={emoji.label}
            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"
          >
            {withSkinTone(emoji, skinTone)}
          </button>
        </div>
      ))}
    </div>
  )
})

function EmojiPicker({
  onSelect,
  skinTone,
  defaultSkinTone = 0,
  onSkinToneChange,
  className,
  "aria-label": ariaLabel = "Emoji picker",
  ...props
}: Omit<React.ComponentProps<"div">, "onSelect"> & {
  onSelect?: (emoji: string) => void
  skinTone?: SkinTone
  defaultSkinTone?: SkinTone
  onSkinToneChange?: (skinTone: SkinTone) => void
}) {
  const id = React.useId()
  const gridRef = React.useRef<HTMLDivElement>(null)
  const searchRef = React.useRef<HTMLInputElement>(null)
  const [library, setLibrary] = React.useState<{
    emojis: Emoji[]
    recent: string[]
  } | null>(null)
  const [hasFailed, setHasFailed] = React.useState(false)
  const [storedSkinTone, setStoredSkinTone] = React.useState<SkinTone>()
  const [query, setQuery] = React.useState("")
  const [activeCell, setActiveCell] = React.useState<EmojiCell>({
    row: 0,
    column: 0,
  })
  const { value: tone, setValue: setTone } = useControllableState({
    value: skinTone ?? storedSkinTone,
    defaultValue: defaultSkinTone,
    onChange: (next: SkinTone) => {
      setStoredSkinTone(next)
      writePreferences({ ...readPreferences(), skinTone: next })
      onSkinToneChange?.(next)
    },
  })

  React.useEffect(() => {
    let isCurrent = true
    loadEmojis()
      .then((emojis) => {
        if (!isCurrent) return
        const preferences = readPreferences()
        setLibrary({ emojis, recent: preferences.recent })
        setStoredSkinTone(preferences.skinTone)
      })
      .catch(() => {
        if (isCurrent) setHasFailed(true)
      })
    return () => {
      isCurrent = false
    }
  }, [])

  const needle = query.trim()
  const layout = React.useMemo(() => {
    if (!library) return layoutSections([], COLUMNS)
    const sections = needle
      ? [
          {
            id: "results",
            label: "Search results",
            emojis: searchEmojis(library.emojis, needle),
          },
        ]
      : buildSections(library.emojis, library.recent)
    return layoutSections(sections, COLUMNS)
  }, [library, needle])

  const resultCount = needle ? (layout.sections[0]?.emojis.length ?? 0) : null
  const tabCell = layout.rows[activeCell.row]?.[activeCell.column]
    ? activeCell
    : { row: 0, column: 0 }

  function focusCell(cell: EmojiCell) {
    setActiveCell(cell)
    gridRef.current
      ?.querySelector<HTMLElement>(
        `[data-row="${cell.row}"][data-column="${cell.column}"]`
      )
      ?.focus()
  }

  function pick(emoji: Emoji) {
    const preferences = readPreferences()
    writePreferences({
      ...preferences,
      recent: addRecent(preferences.recent, emoji.hexcode),
    })
    onSelect?.(withSkinTone(emoji, tone))
  }

  function cellOf(target: EventTarget) {
    const button = (target as HTMLElement).closest<HTMLElement>("[data-row]")
    return button
      ? {
          row: Number(button.dataset.row),
          column: Number(button.dataset.column),
        }
      : null
  }

  function handleGridKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    const cell = cellOf(event.target)
    if (!cell) return
    const next = moveCell(
      layout.rows.map((row) => row.length),
      layout.sections.map((section) => section.firstRow),
      cell,
      event.key,
      event.ctrlKey || event.metaKey
    )
    if (!next) return
    event.preventDefault()
    if (event.key === "ArrowUp" && cell.row === 0) searchRef.current?.focus()
    else focusCell(next)
  }

  function handleSearchKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
    if (event.key === "ArrowDown") {
      event.preventDefault()
      focusCell(tabCell)
    }
    if (event.key === "Enter" && needle && layout.rows[0]) {
      event.preventDefault()
      pick(layout.rows[0][0])
    }
  }

  function scrollToSection(sectionId: string) {
    const grid = gridRef.current
    const section = document.getElementById(`${id}-${sectionId}`)
    if (grid && section) grid.scrollTop = section.offsetTop
  }

  return (
    <div
      role="group"
      aria-label={ariaLabel}
      data-slot="emoji-picker"
      className={cn(
        "flex h-96 w-80 flex-col gap-2 rounded-lg border bg-popover p-2 text-popover-foreground",
        className
      )}
      {...props}
    >
      <Input
        ref={searchRef}
        type="search"
        aria-label="Search emoji"
        placeholder="Search emoji"
        value={query}
        onChange={(event) => {
          setQuery(event.target.value)
          setActiveCell({ row: 0, column: 0 })
          if (gridRef.current) gridRef.current.scrollTop = 0
        }}
        onKeyDown={handleSearchKeyDown}
        className="h-8"
      />
      {!needle && layout.sections.length > 0 && (
        <div aria-hidden className="flex justify-between">
          {layout.sections.map((section) => {
            const Icon = SECTION_ICONS[section.id]
            return (
              <button
                key={section.id}
                type="button"
                tabIndex={-1}
                title={section.label}
                onMouseDown={(event) => event.preventDefault()}
                onClick={() => scrollToSection(section.id)}
                className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground [&_svg]:size-4"
              >
                <Icon />
              </button>
            )
          })}
        </div>
      )}
      {layout.rows.length === 0 ? (
        <p className="min-h-0 flex-1 p-2 text-sm text-muted-foreground">
          {hasFailed
            ? "Emoji failed to load"
            : library
              ? "No emoji found"
              : "Loading emoji"}
        </p>
      ) : (
        <div
          ref={gridRef}
          role="grid"
          aria-label="Emoji"
          aria-colcount={COLUMNS}
          onKeyDown={handleGridKeyDown}
          onFocus={(event) => {
            const cell = cellOf(event.target)
            if (cell) setActiveCell(cell)
          }}
          onClick={(event) => {
            const cell = cellOf(event.target)
            if (cell) pick(layout.rows[cell.row][cell.column])
          }}
          className="relative min-h-0 flex-1 overflow-y-auto"
        >
          {layout.sections.map((section) => (
            <div
              key={section.id}
              id={`${id}-${section.id}`}
              role="rowgroup"
              aria-labelledby={`${id}-${section.id}-label`}
            >
              <div
                id={`${id}-${section.id}-label`}
                role="presentation"
                className="sticky top-0 z-10 bg-popover py-1 text-xs font-medium text-muted-foreground"
              >
                {section.label}
              </div>
              {layout.rows
                .slice(section.firstRow, section.firstRow + section.rowCount)
                .map((emojis, offset) => {
                  const row = section.firstRow + offset
                  return (
                    <EmojiRow
                      key={row}
                      emojis={emojis}
                      row={row}
                      tabColumn={row === tabCell.row ? tabCell.column : null}
                      skinTone={tone}
                    />
                  )
                })}
            </div>
          ))}
        </div>
      )}
      <fieldset className="flex items-center justify-between">
        <legend className="sr-only">Skin tone</legend>
        {SKIN_TONES.map((option) => (
          <label
            key={option.tone}
            title={`${option.label} skin tone`}
            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"
          >
            <input
              type="radio"
              name={`${id}-skin-tone`}
              className="sr-only"
              checked={tone === option.tone}
              onChange={() => setTone(option.tone)}
            />
            <span aria-hidden>{option.emoji}</span>
            <span className="sr-only">{option.label}</span>
          </label>
        ))}
      </fieldset>
      <span role="status" className="sr-only">
        {resultCount === null
          ? ""
          : `${resultCount} ${resultCount === 1 ? "result" : "results"}`}
      </span>
    </div>
  )
}

export { EmojiPicker, type SkinTone }