# Emoji Picker

> Emoji grid popover with category tabs, search, skin-tone selector, recent row and keyboard grid navigation.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/emoji-picker
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/emoji-picker.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/emoji-picker-demo.tsx"
"use client"

import * as React from "react"

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

export default function EmojiPickerDemo() {
  const [message, setMessage] = React.useState("Shipped the new onboarding flow ")
  const inputRef = React.useRef<HTMLInputElement>(null)

  return (
    <div className="w-full max-w-md rounded-2xl border bg-card p-4 shadow-sm">
      <p className="mb-3 text-sm font-medium">Team update</p>
      <div className="flex items-center gap-2">
        <EmojiPicker
          defaultRecent={["👍", "🎉", "❤️"]}
          onSelect={(emoji) => {
            setMessage((m) => m + emoji)
            inputRef.current?.focus()
          }}
        />
        <input
          ref={inputRef}
          value={message}
          onChange={(event) => setMessage(event.target.value)}
          aria-label="Message"
          placeholder="Write a message…"
          className="h-10 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm outline-none transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/40"
        />
      </div>
      <p className="mt-3 text-xs text-muted-foreground">Open the picker, search or browse, and pick a skin tone for hand emoji.</p>
    </div>
  )
}
```

## Source

```tsx title="components/emoji-picker.tsx"
"use client"

import * as React from "react"
import {
  Heart,
  Hand,
  Leaf,
  Lightbulb,
  Plane,
  Search,
  Smile,
  SmilePlus,
  Trophy,
  Utensils,
  type LucideIcon,
} from "lucide-react"

import { cn } from "@/lib/utils"

export type Emoji = {
  /** Plain unicode character(s) */
  char: string
  /** Searchable name, lowercase */
  name: string
  /** Supports Fitzpatrick skin-tone modifiers */
  tone?: boolean
}

type Category = { id: string; label: string; icon: LucideIcon; emoji: Emoji[] }

function list(rows: [string, string, boolean?][]): Emoji[] {
  return rows.map(([char, name, tone]) => ({ char, name, tone }))
}

export const emojiCategories: Category[] = [
  {
    id: "smileys",
    label: "Smileys",
    icon: Smile,
    emoji: list([
      ["😀", "grinning face happy"], ["😃", "smiley big eyes"], ["😄", "smile open mouth"], ["😁", "beaming grin"],
      ["😆", "laughing squint"], ["😅", "sweat smile relief"], ["😂", "tears of joy laugh"], ["🤣", "rolling on floor laughing"],
      ["😊", "blush smiling eyes"], ["😇", "angel halo innocent"], ["🙂", "slight smile"], ["😉", "wink"],
      ["😍", "heart eyes love"], ["🥰", "smiling hearts adore"], ["😘", "blowing kiss"], ["😋", "yum delicious"],
      ["😎", "cool sunglasses"], ["🤩", "star struck wow"], ["🥳", "party celebrate"], ["😏", "smirk"],
      ["😢", "crying sad tear"], ["😭", "loudly crying sob"], ["😡", "angry pouting rage"], ["🤔", "thinking hmm"],
      ["🤗", "hugging face"], ["😴", "sleeping zzz"], ["🤯", "exploding head mind blown"], ["😱", "screaming fear shock"],
    ]),
  },
  {
    id: "people",
    label: "People",
    icon: Hand,
    emoji: list([
      ["👋", "waving hand hello", true], ["👌", "ok hand perfect", true], ["🤞", "crossed fingers luck", true], ["🤟", "love you gesture", true],
      ["🤘", "sign of the horns rock", true], ["👍", "thumbs up like approve", true], ["👎", "thumbs down dislike", true], ["👊", "oncoming fist punch", true],
      ["👏", "clapping hands applause", true], ["🙌", "raising hands celebration", true], ["🙏", "folded hands thanks please", true], ["💪", "flexed biceps strong", true],
      ["🤙", "call me hand", true], ["✌", "victory hand peace", true], ["✋", "raised hand stop high five", true], ["👈", "pointing left", true],
      ["👀", "eyes looking"], ["🧠", "brain smart"],
    ]),
  },
  {
    id: "nature",
    label: "Nature",
    icon: Leaf,
    emoji: list([
      ["🐶", "dog face puppy"], ["🐱", "cat face kitten"], ["🦊", "fox"], ["🐻", "bear"],
      ["🐼", "panda"], ["🐨", "koala"], ["🦁", "lion"], ["🐸", "frog"],
      ["🐵", "monkey face"], ["🐧", "penguin"], ["🦄", "unicorn"], ["🐝", "honeybee"],
      ["🦋", "butterfly"], ["🌸", "cherry blossom flower"], ["🌳", "deciduous tree"], ["🌵", "cactus"],
      ["☀️", "sun sunny"], ["🌈", "rainbow"], ["🔥", "fire hot flame"], ["❄️", "snowflake cold"],
    ]),
  },
  {
    id: "food",
    label: "Food",
    icon: Utensils,
    emoji: list([
      ["🍎", "red apple fruit"], ["🍌", "banana"], ["🍇", "grapes"], ["🍓", "strawberry"],
      ["🍉", "watermelon"], ["🍑", "peach"], ["🥑", "avocado"], ["🍕", "pizza"],
      ["🍔", "hamburger burger"], ["🍟", "french fries"], ["🌮", "taco"], ["🍣", "sushi"],
      ["🍩", "doughnut donut"], ["🍪", "cookie"], ["🍰", "shortcake cake"], ["☕", "hot beverage coffee"],
      ["🍺", "beer mug"], ["🍷", "wine glass"],
    ]),
  },
  {
    id: "activities",
    label: "Activities",
    icon: Trophy,
    emoji: list([
      ["⚽", "soccer ball football"], ["🏀", "basketball"], ["🏈", "american football"], ["🎾", "tennis"],
      ["🏐", "volleyball"], ["🎮", "video game controller"], ["🎯", "direct hit dart target"], ["🎲", "game die dice"],
      ["🎸", "guitar"], ["🎧", "headphone music"], ["🎨", "artist palette art"], ["🏆", "trophy winner"],
    ]),
  },
  {
    id: "travel",
    label: "Travel",
    icon: Plane,
    emoji: list([
      ["🚗", "automobile car"], ["🚕", "taxi"], ["🚌", "bus"], ["🚀", "rocket launch"],
      ["✈️", "airplane flight"], ["🚲", "bicycle bike"], ["🚢", "ship boat"], ["🏠", "house home"],
      ["🏝️", "desert island beach"], ["🗼", "tokyo tower"], ["🗽", "statue of liberty"], ["⛰️", "mountain"],
    ]),
  },
  {
    id: "objects",
    label: "Objects",
    icon: Lightbulb,
    emoji: list([
      ["💡", "light bulb idea"], ["📱", "mobile phone"], ["💻", "laptop computer"], ["⌚", "watch time"],
      ["📷", "camera photo"], ["🔑", "key"], ["🔒", "locked padlock"], ["📚", "books"],
      ["✏️", "pencil write"], ["📌", "pushpin"], ["🎁", "wrapped gift present"], ["💰", "money bag"],
    ]),
  },
  {
    id: "symbols",
    label: "Symbols",
    icon: Heart,
    emoji: list([
      ["❤️", "red heart love"], ["🧡", "orange heart"], ["💛", "yellow heart"], ["💚", "green heart"],
      ["💙", "blue heart"], ["💜", "purple heart"], ["✅", "check mark done"], ["❌", "cross mark no"],
      ["⭐", "star favorite"], ["⚡", "high voltage lightning"], ["💯", "hundred points perfect"], ["⚠️", "warning caution"],
    ]),
  },
]

const allEmoji = emojiCategories.flatMap((c) => c.emoji)

const tones = [
  { label: "Default skin tone", color: "#facc15", modifier: "" },
  { label: "Light skin tone", color: "#fbdcc4", modifier: "\u{1F3FB}" },
  { label: "Medium-light skin tone", color: "#e9c197", modifier: "\u{1F3FC}" },
  { label: "Medium skin tone", color: "#c99a6b", modifier: "\u{1F3FD}" },
  { label: "Medium-dark skin tone", color: "#9b6a43", modifier: "\u{1F3FE}" },
  { label: "Dark skin tone", color: "#5e3f2d", modifier: "\u{1F3FF}" },
]

const COLUMNS = 8

export interface EmojiPickerProps extends Omit<React.ComponentProps<"div">, "onSelect"> {
  /** Called with the chosen character (skin tone applied) */
  onSelect?: (emoji: string) => void
  /** Characters to seed the recent row with */
  defaultRecent?: string[]
  /** Close the popover after picking */
  closeOnSelect?: boolean
  align?: "start" | "end"
  /** Custom trigger content */
  triggerLabel?: React.ReactNode
  /** Opens on first render without moving focus, handy for static previews */
  defaultOpen?: boolean
}

export function EmojiPicker({
  onSelect,
  defaultRecent = [],
  closeOnSelect = true,
  align = "start",
  triggerLabel,
  defaultOpen = false,
  className,
  ...props
}: EmojiPickerProps) {
  const baseId = React.useId()
  const root = React.useRef<HTMLDivElement>(null)
  const trigger = React.useRef<HTMLButtonElement>(null)
  const search = React.useRef<HTMLInputElement>(null)
  const needsFocus = React.useRef(false)
  const focusSearch = React.useRef(false)

  const [open, setOpen] = React.useState(defaultOpen)
  const [query, setQuery] = React.useState("")
  const [category, setCategory] = React.useState(emojiCategories[0].id)
  const [tone, setTone] = React.useState(0)
  const [recent, setRecent] = React.useState<Emoji[]>(() =>
    defaultRecent
      .map((char) => allEmoji.find((e) => e.char === char))
      .filter((e): e is Emoji => Boolean(e))
      .slice(0, COLUMNS)
  )
  const [pos, setPos] = React.useState({ r: 0, c: 0 })
  const [preview, setPreview] = React.useState<Emoji | null>(null)

  const render = (e: Emoji) => (e.tone ? e.char + tones[tone].modifier : e.char)

  const q = query.trim().toLowerCase()
  const sections: { key: string; title: string; items: Emoji[] }[] = q
    ? [{ key: "results", title: "Search results", items: allEmoji.filter((e) => e.name.includes(q)) }]
    : [
        ...(recent.length ? [{ key: "recent", title: "Recently used", items: recent }] : []),
        {
          key: category,
          title: emojiCategories.find((c) => c.id === category)?.label ?? "",
          items: emojiCategories.find((c) => c.id === category)?.emoji ?? [],
        },
      ]

  const rows: { key: string; items: Emoji[] }[] = []
  const sectionRows = sections.map((section) => {
    const start = rows.length
    for (let i = 0; i < section.items.length; i += COLUMNS) {
      rows.push({ key: section.key, items: section.items.slice(i, i + COLUMNS) })
    }
    return { ...section, start }
  })

  const r = Math.min(pos.r, Math.max(rows.length - 1, 0))
  const c = Math.min(pos.c, Math.max((rows[r]?.items.length ?? 1) - 1, 0))
  const cellId = (row: number, col: number) => `${baseId}-cell-${row}-${col}`

  function close(refocus: boolean) {
    setOpen(false)
    setQuery("")
    if (refocus) trigger.current?.focus()
  }

  function toggleOpen() {
    if (open) return close(false)
    focusSearch.current = true
    setOpen(true)
  }

  React.useEffect(() => {
    if (!open) return
    function onPointerDown(event: PointerEvent) {
      if (root.current && !root.current.contains(event.target as Node)) {
        setOpen(false)
        setQuery("")
      }
    }
    document.addEventListener("pointerdown", onPointerDown)
    return () => document.removeEventListener("pointerdown", onPointerDown)
  }, [open])

  // Imperative focus moves only; no state is set here.
  React.useEffect(() => {
    if (!open) return
    if (focusSearch.current) {
      focusSearch.current = false
      search.current?.focus()
    } else if (needsFocus.current) {
      needsFocus.current = false
      document.getElementById(cellId(r, c))?.focus()
    }
  })

  function pick(emoji: Emoji) {
    onSelect?.(render(emoji))
    setRecent((prev) => [emoji, ...prev.filter((e) => e.char !== emoji.char)].slice(0, COLUMNS))
    if (closeOnSelect) close(true)
  }

  function goto(nr: number, nc: number) {
    setPos({ r: nr, c: nc })
    setPreview(rows[nr]?.items[nc] ?? null)
    needsFocus.current = true
  }

  function onCellKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
    const lastRow = rows.length - 1
    switch (event.key) {
      case "ArrowRight":
        event.preventDefault()
        if (c + 1 < rows[r].items.length) goto(r, c + 1)
        else if (r < lastRow) goto(r + 1, 0)
        break
      case "ArrowLeft":
        event.preventDefault()
        if (c > 0) goto(r, c - 1)
        else if (r > 0) goto(r - 1, rows[r - 1].items.length - 1)
        break
      case "ArrowDown":
        event.preventDefault()
        if (r < lastRow) goto(r + 1, Math.min(c, rows[r + 1].items.length - 1))
        break
      case "ArrowUp":
        event.preventDefault()
        if (r > 0) goto(r - 1, Math.min(c, rows[r - 1].items.length - 1))
        else search.current?.focus()
        break
      case "Home":
        event.preventDefault()
        if (event.ctrlKey) goto(0, 0)
        else goto(r, 0)
        break
      case "End":
        event.preventDefault()
        if (event.ctrlKey) goto(lastRow, rows[lastRow].items.length - 1)
        else goto(r, rows[r].items.length - 1)
        break
    }
  }

  function onTabKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, index: number) {
    let next = index
    if (event.key === "ArrowRight") next = (index + 1) % emojiCategories.length
    else if (event.key === "ArrowLeft") next = (index - 1 + emojiCategories.length) % emojiCategories.length
    else if (event.key === "Home") next = 0
    else if (event.key === "End") next = emojiCategories.length - 1
    else return
    event.preventDefault()
    setCategory(emojiCategories[next].id)
    setQuery("")
    setPos({ r: 0, c: 0 })
    document.getElementById(`${baseId}-tab-${emojiCategories[next].id}`)?.focus()
  }

  function onToneKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, index: number) {
    let next = index
    if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % tones.length
    else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index - 1 + tones.length) % tones.length
    else return
    event.preventDefault()
    setTone(next)
    document.getElementById(`${baseId}-tone-${next}`)?.focus()
  }

  const panelId = `${baseId}-panel`
  const iconBtn =
    "inline-flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40"

  return (
    <div ref={root} className={cn("relative inline-block", className)} {...props}>
      <button
        ref={trigger}
        type="button"
        aria-label="Open emoji picker"
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-controls={open ? panelId : undefined}
        onClick={toggleOpen}
        className="inline-flex h-10 min-w-10 items-center justify-center gap-2 rounded-lg border bg-background px-2.5 text-sm shadow-xs transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40"
      >
        {triggerLabel ?? <SmilePlus className="size-5 text-muted-foreground" aria-hidden />}
      </button>

      {open && (
        <div
          id={panelId}
          role="dialog"
          aria-label="Emoji picker"
          onKeyDown={(event) => {
            if (event.key === "Escape") {
              event.preventDefault()
              event.stopPropagation()
              close(true)
            }
          }}
          className={cn(
            "absolute top-full z-50 mt-2 w-80 max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg",
            align === "end" ? "right-0" : "left-0"
          )}
        >
          <div className="border-b p-2">
            <div className="relative">
              <Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
              <input
                ref={search}
                type="search"
                aria-label="Search emoji"
                placeholder="Search emoji…"
                autoComplete="off"
                value={query}
                onChange={(event) => {
                  setQuery(event.target.value)
                  setPos({ r: 0, c: 0 })
                }}
                onKeyDown={(event) => {
                  if (event.key === "ArrowDown" && rows.length) {
                    event.preventDefault()
                    goto(0, 0)
                  } else if (event.key === "Enter" && q && rows[0]) {
                    event.preventDefault()
                    pick(rows[0].items[0])
                  }
                }}
                className="h-9 w-full rounded-lg border bg-background pr-3 pl-8 text-sm outline-none transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/40 [&::-webkit-search-cancel-button]:appearance-none"
              />
            </div>
          </div>

          <div role="tablist" aria-label="Emoji categories" className="flex items-center justify-between gap-0.5 border-b px-2 py-1">
            {emojiCategories.map((cat, index) => {
              const Icon = cat.icon
              const active = !q && cat.id === category
              return (
                <button
                  key={cat.id}
                  id={`${baseId}-tab-${cat.id}`}
                  type="button"
                  role="tab"
                  aria-label={cat.label}
                  aria-selected={active}
                  aria-controls={`${baseId}-grid`}
                  tabIndex={cat.id === category ? 0 : -1}
                  onClick={() => {
                    setCategory(cat.id)
                    setQuery("")
                    setPos({ r: 0, c: 0 })
                  }}
                  onKeyDown={(event) => onTabKeyDown(event, index)}
                  className={cn(
                    iconBtn,
                    active ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
                  )}
                >
                  <Icon className="size-4" aria-hidden />
                </button>
              )
            })}
          </div>

          <div id={`${baseId}-grid`} role="tabpanel" className="max-h-64 overflow-y-auto p-2">
            {rows.length === 0 && (
              <p role="status" className="py-8 text-center text-sm text-muted-foreground">
                No emoji found.
              </p>
            )}
            <div role="grid" aria-label="Emoji" hidden={rows.length === 0}>
              {sectionRows.map((section) => (
                <div key={section.key} className="mb-2 last:mb-0">
                  <h3 className="mb-1 px-1 text-xs font-medium text-muted-foreground">{section.title}</h3>
                  {Array.from({ length: Math.ceil(section.items.length / COLUMNS) }, (_, i) => section.start + i).map((rowIndex) => (
                    <div key={rowIndex} role="row" className="grid grid-cols-8">
                      {rows[rowIndex].items.map((emoji, colIndex) => (
                        <div key={emoji.char} role="gridcell">
                          <button
                            id={cellId(rowIndex, colIndex)}
                            type="button"
                            aria-label={emoji.name.split(" ").slice(0, 3).join(" ")}
                            tabIndex={rowIndex === r && colIndex === c ? 0 : -1}
                            onClick={() => pick(emoji)}
                            onKeyDown={onCellKeyDown}
                            onFocus={() => {
                              setPos({ r: rowIndex, c: colIndex })
                              setPreview(emoji)
                            }}
                            onMouseEnter={() => {
                              setPos({ r: rowIndex, c: colIndex })
                              setPreview(emoji)
                            }}
                            className="flex size-9 items-center justify-center rounded-lg text-xl transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40"
                          >
                            <span aria-hidden>{render(emoji)}</span>
                          </button>
                        </div>
                      ))}
                    </div>
                  ))}
                </div>
              ))}
            </div>
          </div>

          <div className="flex items-center justify-between gap-2 border-t bg-muted/30 px-3 py-2">
            <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground" aria-live="polite">
              <span aria-hidden className="text-lg leading-none">
                {preview ? render(preview) : "🙂"}
              </span>
              <span className="truncate">{preview ? preview.name.split(" ").slice(0, 3).join(" ") : "Pick an emoji"}</span>
            </div>
            <div role="radiogroup" aria-label="Skin tone" className="flex shrink-0 items-center gap-1">
              {tones.map((t, index) => (
                <button
                  key={t.label}
                  id={`${baseId}-tone-${index}`}
                  type="button"
                  role="radio"
                  aria-label={t.label}
                  aria-checked={tone === index}
                  tabIndex={tone === index ? 0 : -1}
                  onClick={() => setTone(index)}
                  onKeyDown={(event) => onToneKeyDown(event, index)}
                  style={{ backgroundColor: t.color }}
                  className={cn(
                    "size-4 rounded-full transition-transform focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40",
                    tone === index ? "scale-110 ring-2 ring-foreground ring-offset-1 ring-offset-background" : "hover:scale-110"
                  )}
                />
              ))}
            </div>
          </div>
        </div>
      )}
    </div>
  )
}
```
