Emoji Picker Panel
Machine-coding brief for an emoji picker — grid, search, categories, keyboard, virtualization notes, and insert callback.
- 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)
- Data — mock 50–100 emoji; mention CDN/json for production
- Popover — absolute panel + dismiss (reuse modal focus ideas without full trap if focus stays in panel)
- 2D keyboard nav — columns count from CSS or fixed
COLS = 8 - MVP — grid + search + select; then categories + recent
- 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
- Using emoji as only React key — duplicates (same glyph, different names) clash
- Focus stuck in panel after close
- Arrow keys scrolling the page — preventDefault
- Search only by glyph — users type names
- 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
onSelectand closes. I’d add recent via localStorage and categories if time remains, and call out virtualization for the complete dataset.
Related on this site
- Typeahead Mentions
- Accessible Modal Dialog
- Autocomplete Search Box
- Machine Coding Interview Framework