ESC

Type to search the knowledge base.

Emoji Picker Panel

Machine-coding brief for an emoji picker — grid, search, categories, keyboard, virtualization notes, and insert callback.

intermediate5 min read
  • machine-coding
  • interview
  • react
  • a11y

Problem statement

Build an emoji picker panel: a popover grid of emoji, optional category tabs and search, inserting the chosen glyph via callback. Interviewers care about grid keyboard navigation, filtering performance, and popover dismiss behavior — not bundling the entire Unicode emoji set from scratch (use a small mock dataset).

Requirements

Must have

  • Toggle button opens/closes panel
  • Grid of emoji buttons from data { emoji, name, category }[]
  • Click emoji → onSelect(emoji) and usually close
  • Keyboard: arrows move focus in grid; Enter selects; Escape closes
  • Focus moves into panel on open; restores to trigger on close
  • Search filters by name (case-insensitive)

Should have

  • Category tabs (Smileys, Food, …)
  • Recently used row (localStorage)
  • Click outside to close

Nice to have

  • Skin tone modifier
  • Virtualized grid for full emoji list
  • Sticky category headers on scroll

Planning (5 minutes out loud)

  1. Data — mock 50–100 emoji; mention CDN/json for production
  2. Popover — absolute panel + dismiss (reuse modal focus ideas without full trap if focus stays in panel)
  3. 2D keyboard nav — columns count from CSS or fixed COLS = 8
  4. MVP — grid + search + select; then categories + recent
  5. Bundle size — don’t inline 3k emoji in the interview story without code-splitting note

Architecture

EmojiPicker
├── TriggerButton
└── Panel (popover)
    ├── SearchInput
    ├── CategoryTabs
    ├── RecentRow
    └── EmojiGrid

Data model

type EmojiItem = {
  emoji: string;
  name: string;
  category: string;
};

type EmojiPickerProps = {
  emojis: EmojiItem[];
  onSelect: (emoji: string) => void;
  columns?: number; // default 8
};

Implementation sketch

Filter + categories

function useEmojiFilter(emojis: EmojiItem[], query: string, category: string | "all") {
  return useMemo(() => {
    const q = query.trim().toLowerCase();
    return emojis.filter((e) => {
      if (category !== "all" && e.category !== category) return false;
      if (!q) return true;
      return e.name.toLowerCase().includes(q) || e.emoji.includes(q);
    });
  }, [emojis, query, category]);
}

Grid keyboard navigation

function moveIndex(
  index: number,
  key: string,
  cols: number,
  length: number
): number {
  const row = Math.floor(index / cols);
  const col = index % cols;
  switch (key) {
    case "ArrowRight":
      return Math.min(length - 1, index + 1);
    case "ArrowLeft":
      return Math.max(0, index - 1);
    case "ArrowDown":
      return Math.min(length - 1, index + cols);
    case "ArrowUp":
      return Math.max(0, index - cols);
    case "Home":
      return row * cols;
    case "End":
      return Math.min(length - 1, row * cols + cols - 1);
    default:
      return index;
  }
}

Panel core

function EmojiPanel({
  emojis,
  onSelect,
  onClose,
  columns = 8,
}: {
  emojis: EmojiItem[];
  onSelect: (e: string) => void;
  onClose: () => void;
  columns?: number;
}) {
  const [query, setQuery] = useState("");
  const [category, setCategory] = useState<string | "all">("all");
  const [active, setActive] = useState(0);
  const filtered = useEmojiFilter(emojis, query, category);
  const listRef = useRef<HTMLDivElement>(null);

  useEffect(() => setActive(0), [query, category]);

  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape") onClose();
    }
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);

  return (
    <div role="dialog" aria-label="Emoji picker" className="emoji-panel">
      <input
        placeholder="Search emoji"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        aria-label="Search emoji"
        autoFocus
      />
      <div
        ref={listRef}
        role="listbox"
        aria-label="Emoji"
        className="emoji-grid"
        style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
        onKeyDown={(e) => {
          if (filtered.length === 0) return;
          if (e.key.startsWith("Arrow") || e.key === "Home" || e.key === "End") {
            e.preventDefault();
            setActive((i) => moveIndex(i, e.key, columns, filtered.length));
          }
          if (e.key === "Enter" && filtered[active]) {
            onSelect(filtered[active].emoji);
          }
        }}
      >
        {filtered.map((item, i) => (
          <button
            key={item.emoji + item.name}
            type="button"
            role="option"
            aria-label={item.name}
            aria-selected={i === active}
            tabIndex={i === active ? 0 : -1}
            onFocus={() => setActive(i)}
            onClick={() => onSelect(item.emoji)}
          >
            <span aria-hidden>{item.emoji}</span>
          </button>
        ))}
      </div>
      {filtered.length === 0 && <p>No emoji found</p>}
    </div>
  );
}

Sync DOM focus when active changes:

useEffect(() => {
  const root = listRef.current;
  const btn = root?.querySelectorAll("button")[active] as HTMLElement | undefined;
  btn?.focus();
}, [active, filtered.length]);

Recent

const RECENT_KEY = "fb.emoji.recent.v1";

function pushRecent(emoji: string) {
  const prev = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]") as string[];
  const next = [emoji, ...prev.filter((e) => e !== emoji)].slice(0, 16);
  localStorage.setItem(RECENT_KEY, JSON.stringify(next));
}

Accessibility essentials

  • Trigger: aria-expanded, aria-haspopup="dialog"
  • Each emoji button has name (aria-label={name}), not only the glyph
  • Roving tabindex in the grid
  • Escape + outside click close; restore focus to trigger
  • Empty search result message

Performance notes

  • Filter with useMemo; debounce search if list is huge
  • Full Unicode sets: virtualize rows, load JSON async, cache by category
  • Avoid rendering hidden categories’ nodes if tabs switch data

Footguns

  1. Using emoji as only React key — duplicates (same glyph, different names) clash
  2. Focus stuck in panel after close
  3. Arrow keys scrolling the page — preventDefault
  4. Search only by glyph — users type names
  5. Massive bundle of emoji JSON on main path

Interview out-loud answer

Picker is a dialog popover with a filterable grid. Data is a small list of emoji + names; production would code-split the full set. Keyboard uses roving focus with 2D arrow math from column count. Select fires onSelect and closes. I’d add recent via localStorage and categories if time remains, and call out virtualization for the complete dataset.

Further reading