# React Emoji Picker: Search, Skin Tones and Phone Sheets

> Build a React emoji picker with ranked search, skin tones, recents and a phone bottom sheet, and see what bundling 1,870 emoji costs: 31 KB gzipped.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Nov 6, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-emoji-picker

## TL;DR

A good React emoji picker has ranked keyword search, category tabs, a recent row, skin tones that stick, a name preview, a virtualized grid, full keyboard support and a bottom sheet on phones. Bundling the data costs about 31 KB gzipped for the 1,870 emoji in Wingo UI's picker and works offline, while fetching it from a CDN keeps it out of your bundle but adds a request on the first open. Either way, lazy-load the picker so pages that never open it pay nothing.

A React emoji picker looks like a grid of smileys until you build one. People type "party" and expect 🎉 first, they want the emoji they always use at the top, they want their skin tone applied to every hand without picking it again, and they open it from a message field on a phone where the keyboard already takes half the screen. Then someone asks why the chat page got 30 KB heavier. This guide covers each of those with the [Emoji Picker](https://wingo-ui.com/components/emoji-picker) from Wingo UI. We build that library, so we are not neutral; outside facts link to their sources and are dated.

## What should a React emoji picker include?

Eight things: ranked search, category tabs, a recent row, skin tones, a preview, a virtualized grid, keyboard support and a phone layout. Check any react emoji picker component against this list:

- **Keyword search with ranking.** A match on a word of the name ("party popper") beats a match on a keyword, which beats a match inside a word. Among equal matches, the popular emoji go first, so "heart" gives ❤️ before 💟.
- **Category tabs** that jump to a section and light up as you scroll past it.
- **A recent row** at the top, saved between visits.
- **Skin tones**, picked once and applied everywhere.
- **A preview** with the emoji large, its name and a shortcode such as `:party_popper:`, because many emoji look alike at 24px.
- **A virtualized grid.** 1,870 buttons is a lot to mount every time a popover opens; render only the rows on screen.
- **Keyboard support**: arrows, Home, End and Page Up / Page Down in the grid, Enter to pick, Escape to clear the query, and typing a letter goes back to search.
- **A phone layout**: 44px cells and a bottom sheet instead of a floating panel.

The Wingo UI picker covers all eight. Its grid uses `@tanstack/react-virtual`, the cells grow to 44px on a coarse pointer, and the result count is announced in a live region for screen readers.

## Should you bundle the emoji data or fetch it?

Bundle it when the picker must open instantly and work offline; fetch it when you want the bytes out of your JavaScript. Every react emoji selector makes this choice, and the libraries make it differently. We measured our own data file on November 6, 2026: `emoji-picker-data.ts` holds 1,870 emoji with their CLDR English names and keywords from Unicode Emoji 15.0, at 108 KB of source, 31 KB with gzip and 26 KB with Brotli. The emoji are text drawn by the system font, so no images load at all.

| Library (as of November 6, 2026) | Where the data comes from | Styling |
|---|---|---|
| Wingo UI Emoji Picker | Bundled in a source file you own, parsed on first use | Design tokens, light and dark |
| [Frimousse](https://frimousse.liveblocks.io/) 0.4.0 | Fetched from Emojibase on jsDelivr by default, cached locally; `emojibaseUrl` points it at your own copy | Unstyled parts |
| [emoji-mart](https://github.com/missive/emoji-mart) (`@emoji-mart/react` 1.1.1, last published January 2023) | Your choice: import `@emoji-mart/data` or fetch it remotely | Styled, themeable |
| [emoji-picker-react](https://github.com/ealush/emoji-picker-react) 5.1.4 | Ships in the package; native emoji by default, image styles load from jsDelivr | Styled, themeable, plus headless primitives |

Bundling means the picker opens at once, works offline and never depends on a third-party CDN being up. Fetching keeps the bytes out of your JavaScript and lets you update emoji without shipping code, at the price of a request (and a spinner) on the first open. The emoji-mart README lists the same trade-off for its two modes. If you want an emoji-picker-react alternative because of data or styling, this table is where the choice happens.

What matters more than which side you pick: do not load any of it on pages where nobody opens the picker. Our picker parses the rows only on first use, but the file is still in the chunk that imports it, so lazy-load the component.

```tsx
"use client"

import { useState } from "react"
import dynamic from "next/dynamic"
import { SmilePlus } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover } from "@/components/ui/popover"

// the picker and its data load on the first open, not with the page
const EmojiPicker = dynamic(() => import("@/components/ui/emoji-picker"), {
  ssr: false,
  loading: () => <p className="p-4 text-sm text-muted-foreground">Loading emoji…</p>,
})

export function LazyEmojiButton({ onPick }: { onPick: (emoji: string) => void }) {
  const [open, setOpen] = useState(false)
  return (
    <Popover
      open={open}
      onOpenChange={setOpen}
      size="auto"
      padding="none"
      sheetTitle="Add emoji"
      trigger={
        <Button variant="ghost" size="icon" radius="full" aria-label="Add emoji">
          <SmilePlus />
        </Button>
      }
    >
      <EmojiPicker
        variant="plain"
        onSelect={({ emoji }) => {
          onPick(emoji)
          setOpen(false)
        }}
      />
    </Popover>
  )
}
```

The [Popover](https://wingo-ui.com/components/popover) mounts its content only when it opens, so `next/dynamic` starts the import on the first tap. The cost is a short loading state once per visit, and on a phone this version shows the picker at its set `height` inside the sheet. If you would rather have it instant and filling the sheet, import `EmojiPickerPopover` directly and accept the 31 KB on that route.

## How do you add the emoji picker to a React app?

Install it with the CLI. It copies `emoji-picker.tsx` and the data file into `components/ui/`, plus what they import (Button, Popover, Tooltip and a few hooks). It is a Pro item, so the CLI needs a Pro account (`npx wingo-ui@latest login`):

```bash
npx wingo-ui@latest add emoji-picker
```

`EmojiPicker` renders inline; `EmojiPickerPopover` adds a trigger and opens the picker in a popover. Both report a pick through `onSelect`, which receives `{ emoji, base, name, keywords, category, skinTones }`: the emoji with the skin tone applied, the one without, and its data. If you only need the string, `onValueChange` gets just the emoji.

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

export function StatusEmoji({ onChange }: { onChange: (emoji: string) => void }) {
  return (
    <EmojiPicker
      columns={9}
      size="md"
      height={296}
      maxRecent={24}
      onSelect={({ emoji }) => onChange(emoji)}
    />
  )
}
```

Search for "pizza" or "rocket", hover an emoji to see it in the preview footer, then open the hand button and pick a tone:

> Live demo (Emoji Picker): Search for an emoji, hover one to read its name, then pick a skin tone and watch every hand change. Try it at [Emoji Picker](https://wingo-ui.com/components/emoji-picker) and install it with `npx wingo-ui@latest add emoji-picker`.

Every visual part is a prop: `size` (32, 36 or 44px cells), `radius`, `tone` for the selected tint, `variant="plain"` when it sits in a card that already has a fill, and `showTabs`, `showPreview`, `showSkinTones` and `categories` to trim it down. The recent emoji and the skin tone are saved in `localStorage` under `storageKey` (default `emoji-picker`); pass an empty string to keep them in memory, or control them with `recent` and `skinTone` if you store preferences on the server.

## How do you insert an emoji at the cursor in a message composer?

Save the field's selection while it has focus, then splice the emoji into the text at that position. Quick usage examples, ours included, append the emoji to the end of the text, which is wrong as soon as someone moves the caret:

```tsx
"use client"

import { useRef, useState } from "react"
import { EmojiPickerPopover } from "@/components/ui/emoji-picker"

export function MessageComposer() {
  const [text, setText] = useState("")
  const fieldRef = useRef<HTMLTextAreaElement>(null)
  const caret = useRef<{ start: number; end: number } | null>(null)

  const remember = () => {
    const field = fieldRef.current
    if (field) caret.current = { start: field.selectionStart, end: field.selectionEnd }
  }

  const insert = (emoji: string) => {
    const { start, end } = caret.current ?? { start: text.length, end: text.length }
    setText(text.slice(0, start) + emoji + text.slice(end))
    caret.current = { start: start + emoji.length, end: start + emoji.length }
  }

  return (
    <div className="flex items-end gap-1 rounded-3xl bg-foreground/5 p-1 ps-4">
      <textarea
        ref={fieldRef}
        value={text}
        rows={1}
        aria-label="Message"
        placeholder="Message Emma Carter"
        onChange={(event) => setText(event.target.value)}
        onSelect={remember}
        onBlur={remember}
        className="min-h-9 flex-1 resize-none bg-transparent py-2 text-base outline-hidden md:text-sm"
      />
      <EmojiPickerPopover side="top" align="end" closeOnSelect={false} onSelect={({ emoji }) => insert(emoji)} />
    </div>
  )
}
```

`closeOnSelect={false}` keeps the picker open for several picks in a row, which is how people use it in chat, and the caret ref moves forward so each pick lands after the last one. Note `emoji.length`: 👍🏽 is 4 UTF-16 units, so the caret math must use the string length, never 1.

## How does an emoji picker behave on a phone?

It should open as a bottom sheet with 44px cells and leave the keyboard closed until you ask for it. Three things go wrong on phones, and each has a fix.

**A floating panel does not fit.** A 300px popover anchored to a 44px button covers the thumb that opened it and fights the keyboard for space. Below 768px, `EmojiPickerPopover` opens a bottom sheet you can drag down, and the picker fills it at `min(64dvh, 520px)` with as many 44px columns as the width allows. The sheet pads its bottom with `env(safe-area-inset-bottom)` so the last row clears the home indicator. We wrote about the sheet itself in [React bottom sheet for the web](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web).

**The keyboard pops up when you did not ask for it.** On desktop the popover focuses the search field so you can type at once. On a touch screen it focuses the panel instead, so opening the picker shows the grid without the keyboard; tapping the search field brings it up. Below 768px the search input uses 16px text, so iOS does not zoom the page, and `inputMode="search"` gives the keyboard a search key.

**Taps land on the wrong cell.** At 36px a thumb hits the neighbor. On a coarse pointer every cell grows to at least 44px, whatever `size` says, and cells carry `touch-manipulation` so a double tap does not zoom.

One more: input methods for languages like Japanese compose text before committing it. The search ignores Enter while `isComposing` is true, so confirming a word does not pick the first emoji.

## How do emoji picker skin tones work?

Unicode defines five skin tone modifiers, U+1F3FB to U+1F3FF. A modifier goes right after the code point that takes it: 👍 plus U+1F3FD gives 👍🏽. Emoji with two people, such as people holding hands, take a modifier after each person, and most emoji take none.

The data file stores, for each emoji, where its modifier goes, so the picker applies one choice to every hand and person and leaves 🍕 alone. The choice is saved under `<storageKey>:skin-tone`. Outside the picker, `applySkinTone("👍", "medium")` returns 👍🏽, and `searchEmojis(query, { limit })` gives you the same ranked search for a `:shortcode` autocomplete in a plain text field.

## Is there a React emoji picker for shadcn?

Not in shadcn/ui itself: as of November 6, 2026 its [components page](https://ui.shadcn.com/docs/components) lists Popover but no emoji picker. A react emoji picker shadcn projects can add comes from a third-party registry instead: Frimousse and emoji-picker-react 5 both publish a shadcn registry item in their READMEs, and Wingo UI's picker installs the same way (source files in `components/ui`, `cn()`, design tokens) through its own CLI. If you also use `@mentions` in the same composer, [React mentions](https://wingo-ui.com/blog/react-mentions) covers that field.

## Should message reactions use the full picker?

No. Reacting to a message needs a handful of choices, not 1,870. The free [Emoji Reaction](https://wingo-ui.com/components/emoji-reaction) button opens a short bar of five Apple emoji (images from a CDN by default, set with `emojiBaseUrl`) and sends copies of the pick floating up; press and drag straight onto an emoji to pick it in one gesture:

> Live demo (Emoji Reaction): Press the button and drag onto an emoji, or tap it open and pick one. Try it at [Emoji Reaction](https://wingo-ui.com/components/emoji-reaction) and install it with `npx wingo-ui@latest add emoji-reaction`.

The [Comment Thread](https://wingo-ui.com/components/comment-thread) takes the same approach: its `reactions` prop sets the row of quick reactions under each comment (👍 ❤️ 😂 🎉 😮 🙏 by default). Keep the full picker for the composer, or behind a "more" button next to the reaction chips.

## What are the common emoji picker pitfalls?

Flags, string length, hydration and old fonts cause most of the bug reports:

- **Flags on Windows.** Segoe UI Emoji has no national flag glyphs, so 🇺🇸 renders as "US", as a moderator answer on [Microsoft Q&A](https://learn.microsoft.com/en-us/answers/questions/5554132/it-s-time-for-microsoft-to-fully-support-flag-emoj) confirms (checked November 6, 2026). Set `emojiFont` to a stack that starts with a webfont that has flags if your users need them, or hide the category with `categories`.
- **Counting characters.** `"👍🏽".length` is 4. For a character limit, count graphemes with `[...new Intl.Segmenter().segment(text)].length`.
- **Hydration.** Recent emoji come from `localStorage`, which the server cannot read. The picker renders the server value first and reads storage after hydration, so the markup matches; do the same if you control `recent` yourself.
- **Old data and old fonts.** Our data stops at Emoji 15.0, so emoji from later Unicode versions are not in the grid, and a device whose font is older than that still draws some of these as empty boxes.

For more component guides like this one, see the [component guides category](https://wingo-ui.com/blog/category/components) and the pillar on [React form components](https://wingo-ui.com/blog/react-form-components-guide).

## Next step

Open the [Emoji Picker docs](https://wingo-ui.com/components/emoji-picker) to try every prop in the playground, then install it with `npx wingo-ui@latest add emoji-picker`. It is part of Wingo UI Pro ([pricing](https://wingo-ui.com/pricing)); the Popover and Emoji Reaction are free with a free account.

## Components in this post

- [Emoji Picker](https://wingo-ui.com/components/emoji-picker) (Pro): A React emoji picker with search, category tabs, recent emoji, skin tones and a preview, bundling 1,870 emoji with names and keywords. Install: `npx wingo-ui@latest add emoji-picker`
- [Emoji Reaction](https://wingo-ui.com/components/emoji-reaction) (Free): A React emoji reaction button in the chat tapback style: it opens a bar of Apple emoji and sends copies of your pick floating up. Install: `npx wingo-ui@latest add emoji-reaction`
- [Popover](https://wingo-ui.com/components/popover) (Free): A React popover for rich content anchored to a trigger, like a quick filter, form or share panel, that becomes a bottom sheet on phones. Install: `npx wingo-ui@latest add popover`
- [Comment Thread](https://wingo-ui.com/components/comment-thread) (Pro): A React comment thread for records: replies, reactions, edit and delete, @mentions and a composer that sends optimistically with retry. Install: `npx wingo-ui@latest add comment-thread`

## FAQ

### What is the best React emoji picker library?

It depends on how much you want to own. As of November 6, 2026, emoji-picker-react (5.1.4) is a styled drop-in with headless primitives, Frimousse (0.4.0) is unstyled and fetches its data from a CDN, and Wingo UI's Emoji Picker installs as source code with bundled data, design tokens and a phone sheet.

### Does shadcn/ui have an emoji picker?

No. As of November 6, 2026 the shadcn/ui components page lists Popover but no emoji picker. Frimousse and emoji-picker-react publish shadcn registry items, and Wingo UI installs its picker as source through its own CLI.

### How do skin tones work in an emoji picker?

A skin tone is one of five Unicode modifier characters (U+1F3FB to U+1F3FF) placed after the code point that takes it, so 👍 becomes 👍🏽. Only some emoji accept one, so a picker should apply the chosen tone to all of those, leave the rest alone and remember the choice.

### Why do flag emoji show as letters on Windows?

Segoe UI Emoji, the Windows emoji font, has no national flag glyphs, so a flag renders as its two-letter region code. Point the picker's font stack at a webfont that has flags if your users need them.

### Is the Wingo UI Emoji Picker free?

No, the Emoji Picker and Comment Thread are part of Wingo UI Pro ($8 a month, $80 a year or $150 once). The Popover it opens in and the Emoji Reaction button are free.

---

Source: https://wingo-ui.com/blog/react-emoji-picker
