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)
100개 이상의 언어로 이모지 이름과 검색 키워드를 포함한 로케일 데이터를 제공하는 유니코드 프로젝트.
Data

The Common Locale Data Repository includes emoji annotations (short names, keywords) in 60+ languages. The emoji-test.txtemoji-test.txt
모든 이모지 시퀀스의 자격 상태, 코드 포인트, 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)
100개 이상의 언어로 이모지 이름과 검색 키워드를 포함한 로케일 데이터를 제공하는 유니코드 프로젝트.
emoji-test.txt emoji-test.txt
모든 이모지 시퀀스의 자격 상태, 코드 포인트, CLDR 이름을 나열하는 유니코드 공식 파일.

관련 스토리