Building an Emoji Picker Component from Scratch

Building an Emoji Picker Component from Scratch

Emoji pickers look simple on the surface, but building one that feels fast and accessible involves decisions about data management, virtualization, skin tone state, keyboard navigation, and search. This guide covers the key architectural decisions and implementation patterns.

Data Sourcing

The foundation of any emoji picker is a structured dataset. Good options include:

Unicode CLDRCLDR (CLDR)
通用语言环境数据存储库,Unicode项目之一,提供包括100多种语言表情符号名称和搜索关键词在内的本地化数据。
Data

The Common Locale Data Repository includes emoji annotations (short names, keywords) in 60+ languages. The emoji-test.txtemoji-test.txt
Unicode官方文件,列出所有表情符号序列及其限定状态、码位和CLDR短名称。
file from the Unicode UCD provides the canonical ordered list of all fully-qualified emoji.

# Download the emoji test file
curl -O https://unicode.org/Public/emoji/latest/emoji-test.txt

emoji-datasource

The emoji-datasource npm package provides pre-processed JSON with categories, names, skin tone variants, and platform image URLs:

npm install emoji-datasource
import data from 'emoji-datasource/emoji.json';

// Structure of each entry:
// {
//   name: "GRINNING FACE",
//   unified: "1F600",
//   short_names: ["grinning"],
//   category: "Smileys & People",
//   sort_order: 1,
//   skin_variations: {
//     "1F3FB": { unified: "1F600-1F3FB", ... },
//     ...
//   }
// }

// Build a usable array
const emojis = data.map(e => ({
  emoji: String.fromCodePoint(...e.unified.split('-').map(h => parseInt(h, 16))),
  name: e.name.toLowerCase(),
  keywords: e.short_names,
  category: e.category,
  skinVariations: e.skin_variations,
}));

Component Architecture

A minimal emoji picker has these layers:

EmojiPicker
├── SearchBar
├── CategoryNav (tabs or sidebar)
├── EmojiGrid (virtualized)
│   └── EmojiButton × N
├── SkinTonePicker
└── Preview (hovered emoji name)

React Implementation

import { useState, useMemo, useCallback, useRef } from 'react';
import { FixedSizeGrid } from 'react-window';
import emojis from './emoji-data';

const COLUMN_COUNT = 8;
const CELL_SIZE = 40;

export function EmojiPicker({ onSelect }) {
  const [query, setQuery] = useState('');
  const [skinTone, setSkinTone] = useState(null); // null = default
  const [category, setCategory] = useState('all');
  const [hovered, setHovered] = useState(null);

  const filtered = useMemo(() => {
    let list = emojis;
    if (category !== 'all') {
      list = list.filter(e => e.category === category);
    }
    if (query.trim()) {
      const q = query.toLowerCase();
      list = list.filter(e =>
        e.name.includes(q) || e.keywords.some(k => k.includes(q))
      );
    }
    return list;
  }, [query, category]);

  const rows = useMemo(() => {
    const result = [];
    for (let i = 0; i < filtered.length; i += COLUMN_COUNT) {
      result.push(filtered.slice(i, i + COLUMN_COUNT));
    }
    return result;
  }, [filtered]);

  const getEmoji = useCallback((item) => {
    if (skinTone && item.skinVariations?.[skinTone]) {
      const unified = item.skinVariations[skinTone].unified;
      return String.fromCodePoint(...unified.split('-').map(h => parseInt(h, 16)));
    }
    return item.emoji;
  }, [skinTone]);

  const Cell = useCallback(({ columnIndex, rowIndex, style }) => {
    const item = rows[rowIndex]?.[columnIndex];
    if (!item) return <div style={style} />;
    const char = getEmoji(item);
    return (
      <div style={style}>
        <button
          className="emoji-btn"
          onClick={() => onSelect(char)}
          onMouseEnter={() => setHovered(item.name)}
          onMouseLeave={() => setHovered(null)}
          aria-label={item.name}
          title={item.name}
        >
          {char}
        </button>
      </div>
    );
  }, [rows, getEmoji, onSelect]);

  return (
    <div className="emoji-picker" role="dialog" aria-label="Emoji picker">
      <SearchBar value={query} onChange={setQuery} />
      <CategoryNav value={category} onChange={setCategory} />
      <FixedSizeGrid
        columnCount={COLUMN_COUNT}
        columnWidth={CELL_SIZE}
        rowCount={rows.length}
        rowHeight={CELL_SIZE}
        height={320}
        width={COLUMN_COUNT * CELL_SIZE}
      >
        {Cell}
      </FixedSizeGrid>
      <div className="picker-footer">
        <SkinTonePicker value={skinTone} onChange={setSkinTone} />
        {hovered && <span className="preview-name">{hovered}</span>}
      </div>
    </div>
  );
}

Virtualization

A full emoji dataset has 3,700+ characters. Rendering all of them as DOM nodes simultaneously causes layout thrashing. Use a virtual list/grid:

  • react-window: Lightweight, renders only visible cells
  • @tanstack/virtual: Framework-agnostic, more flexible
  • CSS content-visibility: auto: A CSS-only approach for simpler cases
/* CSS-only approach for moderate emoji counts */
.emoji-grid {
  display: grid;
  grid-template-columns: repeat(8, 40px);
}

.emoji-btn {
  content-visibility: auto;
  contain-intrinsic-size: 40px 40px;
}

Search Implementation

Fast local search requires pre-processing at startup:

// Pre-build a search index at initialization
const searchIndex = emojis.map(e => ({
  ...e,
  searchString: [e.name, ...e.keywords].join(' ').toLowerCase(),
}));

function searchEmoji(query) {
  if (!query.trim()) return emojis;
  const q = query.toLowerCase().trim();
  // Prioritize name matches, then keyword matches
  const nameMatches = searchIndex.filter(e => e.name.includes(q));
  const kwMatches = searchIndex.filter(
    e => !e.name.includes(q) && e.keywords.some(k => k.includes(q))
  );
  return [...nameMatches, ...kwMatches];
}

For fuzzy search, integrate a library like fuse.js:

import Fuse from 'fuse.js';

const fuse = new Fuse(emojis, {
  keys: ['name', 'keywords'],
  threshold: 0.3,
  includeScore: true,
});

function fuzzySearch(query) {
  return fuse.search(query).map(r => r.item);
}

Skin Tone State

Skin tone preferences should persist across sessions:

const SKIN_TONE_KEY = 'emoji-picker-skin-tone';

// Fitzpatrick modifiers
const SKIN_TONES = {
  default: null,
  light: '1F3FB',      // 🏻
  mediumLight: '1F3FC', // 🏼
  medium: '1F3FD',     // 🏽
  mediumDark: '1F3FE', // 🏾
  dark: '1F3FF',       // 🏿
};

function useSkinTone() {
  const [tone, setTone] = useState(
    () => localStorage.getItem(SKIN_TONE_KEY) || null
  );

  const updateTone = useCallback((newTone) => {
    setTone(newTone);
    if (newTone) {
      localStorage.setItem(SKIN_TONE_KEY, newTone);
    } else {
      localStorage.removeItem(SKIN_TONE_KEY);
    }
  }, []);

  return [tone, updateTone];
}

Keyboard Navigation

Keyboard navigation is essential for accessibility and power users:

function useGridKeyboard({ columns, rowCount, onSelect }) {
  const [focused, setFocused] = useState({ row: 0, col: 0 });

  const handleKeyDown = useCallback((e) => {
    const { row, col } = focused;
    switch (e.key) {
      case 'ArrowRight':
        e.preventDefault();
        setFocused({ row, col: Math.min(col + 1, columns - 1) });
        break;
      case 'ArrowLeft':
        e.preventDefault();
        setFocused({ row, col: Math.max(col - 1, 0) });
        break;
      case 'ArrowDown':
        e.preventDefault();
        setFocused({ row: Math.min(row + 1, rowCount - 1), col });
        break;
      case 'ArrowUp':
        e.preventDefault();
        setFocused({ row: Math.max(row - 1, 0), col });
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        onSelect(focused);
        break;
    }
  }, [focused, columns, rowCount, onSelect]);

  return { focused, handleKeyDown };
}

Performance Tips

  1. Lazy-load category data: Only parse emojis for the active category
  2. Memoize filtered results: Avoid re-filtering on unrelated state changes
  3. Debounce search input: 150–200ms debounce prevents excessive filtering
  4. Use CSS sprite sheets: Platform-specific emoji image sets (Twitter, Google) ship as sprite sheets for faster loading than individual files
import { useDeferredValue, useMemo } from 'react';

function EmojiPicker({ onSelect }) {
  const [rawQuery, setRawQuery] = useState('');
  // Defer search processing to avoid blocking input
  const query = useDeferredValue(rawQuery);

  const results = useMemo(() => searchEmoji(query), [query]);
  // ...
}

Explore More on EmojiFYI

相关工具

🔀 平台比较 平台比较
比较表情符号在 Apple、Google、Samsung、Microsoft 等平台上的渲染效果,并排查看视觉差异。
⌨️ 表情符号键盘 表情符号键盘
按分类浏览并复制 3,953 个表情符号。适用于任何浏览器,无需安装。
🔍 序列分析器 序列分析器
将 ZWJ 序列、肤色修饰符、键帽序列和旗帜对解析为独立组成部分。

术语表词条

CLDR (CLDR) CLDR (CLDR)
通用语言环境数据存储库,Unicode项目之一,提供包括100多种语言表情符号名称和搜索关键词在内的本地化数据。
emoji-test.txt emoji-test.txt
Unicode官方文件,列出所有表情符号序列及其限定状态、码位和CLDR短名称。
统一码 统一码
为所有书写系统和符号集(包括表情符号)中的每个字符分配唯一编号的通用字符编码标准。
表情符号(Emoji) 表情符号(Emoji)
来自日语「絵文字」(えもじ),意为「图画文字」,是用于数字通信中表达想法、情感和事物的小型图形符号。

相关故事