Design an emoji and GIF picker
A popup that lets people find and insert one of nearly 4,000 emoji or a GIF from a search service. The hard parts are the emoji data (sequences, skin tones, keywords per language, hundreds of KB), emoji the device can't draw, a grid of thousands of cells that stays fast, local search, recents, a full keyboard and screen reader model, inserting at the caret, and a GIF tab that talks to a provider like GIPHY.
The question
Design the emoji and GIF picker for our chat product. It opens from a button in the message composer and from the add-reaction button on every message. It should work in 20 languages, on desktop and phones, and with a keyboard and a screen reader.
Walk me through the emoji data and how it loads, how the grid stays fast, search, skin tones and recents, keyboard and accessibility, how the chosen emoji gets into the message, and how the GIF tab works.
Real-life examples: The emoji and GIF picker in Slack and Discord, Reaction picker on GitHub issues, Emoji panel in WhatsApp Web and iMessage, emoji-mart (React library), emoji-picker-element (web component)
Requirements: what to ask before drawing anything
An emoji picker is the small popup you open from a smiley button. It shows emoji in a grid, grouped into categories like Smileys, People, Animals, and Food. You can scroll, click a tab to jump to a category, or type to search.
Pick one and it lands in your message, or as a reaction on someone else's message. Many chat products add a second tab for GIFs: short looping animations found through a search service like GIPHY.
It looks like a box of pictures. It is really a small search engine, a data loading problem, a big keyboard grid, and a client for a third-party API, all inside a popup that must open in under 100 ms. That's why interviewers like it: it fits in 45 minutes and still has real depth.
Start by asking questions. Each answer below changes the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Where does it open? | From the composer (insert into text) and from add reaction on a message (send one emoji, close). | One picker with an onSelect callback. The caller decides what selecting means. |
| Which emoji? | All standard Unicode emoji (nearly 4,000 including skin tone and family sequences) plus custom emoji per workspace. | A data set of hundreds of KB that must load lazily, and a second source for custom images. |
| Which languages? | 20 locales. People search in their own language: "perro" should find the dog. | Keywords per locale, loaded on demand, and a search index built on the client. |
| Native glyphs or images? | Native by default, so emoji look like the rest of the text on that device. | We must detect emoji the device can't draw, and hide or replace them. |
| Which devices? | Desktop browsers, plus phone browsers. Phones also have the OS emoji keyboard. | On phones the picker is mostly for reactions and custom emoji, shown as a bottom sheet. |
| Recents and skin tone? | Yes: a frequently used row and one saved skin tone, synced across devices if signed in. | Local storage first, server sync second, and a scoring rule for "frequent". |
| GIFs? | Yes, a GIF tab with trending and search, filtered to a safe content rating. | A third-party API, request races, attribution, and heavy media to keep light. |
| Accessibility target? | WCAG 2.2 AA. Every action by keyboard, every emoji announced by name. | A grid keyboard model, a search box that keeps focus, and live announcements. |
Functional requirements
- Show emoji in a scrollable grid grouped by category, with a tab bar that jumps to each category and highlights the one in view.
- Search as you type in the user's language, by name, keyword, or
:shortcode:like:thumbsup:. - Show a Frequently used section first, ranked by how often and how recently each emoji was picked.
- Apply a saved skin tone to every emoji that supports one, and let the user pick a different tone for one emoji by long press or right click.
- Show custom emoji (the workspace's own images) in their own category, searchable by name.
- Insert the chosen emoji at the caret in the composer, or send it as a reaction.
- A GIF tab: trending GIFs when empty, search results as you type, click to send.
Non-functional requirements
- Fast to open: the popup appears within 100 ms of the click on a mid-range laptop, even the first time in a session. Search results update within one frame (16 ms) of each keystroke.
- Light: the picker is not in the main bundle. Its code is loaded on first open or idle prefetch, and emoji data is cached so the second open costs zero bytes.
- Smooth: scrolling through all categories keeps 60 frames per second on a phone. That rules out rendering every emoji button at once.
- Correct on every device: never show an empty box or a broken emoji that splits into two pictures.
- Accessible: arrow keys move through the grid, Enter picks, Escape closes, and a screen reader hears "thumbs up, medium skin tone" not a code point.
- Safe GIFs: content rating filter on by default, no API key that grants more than search, and the provider's attribution shown.
Out of scope
- Uploading custom emoji (an admin flow; we only display and search them).
- Stickers and animated emoji packs (same ideas as custom emoji, more media).
- Rendering reactions on messages and their counts (the discord guide covers that).
- The composer itself: we only need its "insert this text at the caret" API.
Check your understanding · Exercise 1
Which answers to scope questions change the design the most? Select all that apply.
What makes an emoji picker hard
Before picking any approach, name the forces that push on every later decision. For an emoji picker there are six. Keep them in mind: each decision chapter below answers one of them.
- An emoji is not one character. "Woman firefighter, dark skin tone" is four code points glued together. A family can be seven. Code that treats emoji as single characters breaks search, cursor movement, and storage.
- The data is big and multilingual. Nearly 4,000 emoji, each with a name, a group, keywords, shortcodes, and skin tone variants. With English keywords that's a few hundred KB of JSON. Twenty languages multiply the keyword part by twenty.
- Every device draws a different set. Emoji are drawn by the OS font. A phone that hasn't updated in two years doesn't know the newest emoji, and shows an empty box (called tofu) or two separate pictures where one was meant.
- Thousands of cells in one scroll area. Even with skin tones applied as a rule, the grid holds about 1,900 emoji. Rendering that many buttons costs tens of milliseconds of layout and a lot of memory, every time the popup opens. Popups open often.
- A grid that is also a search box. Typing must keep focus in the search field while arrow keys move through a two-dimensional grid with rows of uneven length. Screen reader users need to hear names, not pictures.
- A third-party service in the middle. The GIF tab depends on a provider such as GIPHY: its rate limits, its attribution rules, its content ratings, and heavy media that can eat a phone's data plan.
Notice what is not on the list: the server. Apart from GIFs, custom emoji, and syncing recents, an emoji picker is a client problem, so the depth is in data formats, browser caching, rendering, and accessibility.
Also notice how often the picker opens. In a busy chat, a user may react ten times an hour. So the cost that matters most is not the first open of the day, but the 50th. The design should make repeat opens nearly free: data already parsed in memory or in IndexedDB, the grid component reused, and recents ready.
Think first
In JavaScript, what does "\u{1F469}\u200D\u{1F692}".length return for "woman firefighter", and why does it matter for a picker?
It returns 5. JavaScript strings count UTF-16 code units. Woman (U+1F469) and fire engine (U+1F692) are outside the basic plane, so each takes two units, and the zero width joiner takes one.
It matters because anything that slices strings by length or by index can cut an emoji in half: trimming a message to 280 "characters", moving the caret, or storing recents as single characters. Use Intl.Segmenter with granularity: "grapheme" to count what the user sees as one character, and store emoji as whole strings.
The emoji data: what an emoji really is, and how to load it
Start with the data, because every other part reads from it. The Unicode Consortium publishes the emoji list, and it grows every year.
Emoji 16.0 (2024) has 3,790 emoji "recommended for general interchange" (RGI: the ones vendors are expected to draw). Emoji 17.0 (2025) brings it to 3,953, mostly through new skin tone variants of people emoji. Emoji 18.0 (September 2026) adds 19 more, for 3,972. The design must assume the list keeps growing.
Code points, sequences, and variants
A code point is one number in Unicode, written like U+1F44D (thumbs up). Only about 1,400 emoji are a single code point (some of them plus a variation selector, below). The rest are sequences: several code points that the font draws as one picture. There are four building blocks.
- Skin tone modifiers: five code points,
U+1F3FBtoU+1F3FF, based on the Fitzpatrick scale. Put one after a person or hand and it changes the skin tone: thumbs up plusU+1F3FDis thumbs up, medium skin tone. Not every emoji accepts one: a cat doesn't. - Zero width joiner (ZWJ):
U+200D, an invisible glue. Woman + ZWJ + fire engine draws as woman firefighter. Families, couples, professions, and gendered variants (man running, woman running) are ZWJ sequences. - Variation selector 16:
U+FE0Fasks for the colorful emoji style. The heartU+2764is older than emoji and draws as a black text symbol without it. Data must store the fully-qualified form, withU+FE0F, so it renders right everywhere. - Flags: two regional indicator letters, like the letters for U and S, form a country flag. Windows doesn't ship flag emoji in its system font, so Chromium-based browsers on Windows show the two letters instead.
Intl.Segmenter, and keep FE0F: it is what makes the heart red.A sequence can also fail halfway. If a device knows "woman" and "fire engine" but not the ZWJ sequence that joins them, it draws both pictures side by side. That's worse than tofu, because it looks like the user sent two emoji. Chapter 4 handles this.
Names, keywords, and shortcodes
Each emoji needs words, for search and for screen readers. There are two sources, and they serve different people.
- CLDR annotations: the Unicode Common Locale Data Repository gives each emoji a short name (used for text-to-speech, like "thumbs up") and a list of keywords, translated per locale. Spanish gives "pulgar hacia arriba" with keywords like "arriba" and "mano". This is what search in 20 languages needs.
- Shortcodes: the
:colon:names power users type, like:+1:or:tada:. They are not a standard. GitHub uses its gemoji set, Slack and Discord each have their own lists, and libraries like Emojibase publish several packs. Shortcodes are mostly English, and people expect the same ones the product has always used.
So pick one shortcode pack that matches your product's history, and use CLDR for names and keywords in every locale. Don't translate shortcodes: someone typing :joy: in a Spanish UI still means the same emoji.
How big the data is
A full English record per emoji (character, name, group, keywords, shortcodes, version, five skin tone variants) is big. The emojibase-data v5 English file was 854 KB, and its compact version 543 KB, as the emoji-picker-element docs point out. Gzip typically shrinks JSON like this by about 80%, so roughly 110 to 170 KB on the wire. That is more than many whole pages' JavaScript, and parsing it costs tens of milliseconds on a phone.
Three tricks shrink it. First, skin tone variants are rules, not data: store a flag "accepts a tone" and build the variant string on the fly. Second, keep keywords out of the base file: the grid needs only the character, group, and name. Third, split keywords by locale, so a Spanish user downloads the Spanish file only.
// emoji-base.json: about 1,900 rows (tones are a rule), roughly 30 KB gzipped (estimate)
type EmojiBase = {
id: string; // "1f44d", the unified code, stable key
char: string; // the fully-qualified string to insert
group: number; // 0 = smileys, 1 = people, ...
order: number; // position in the official order
version: number; // emoji version, e.g. 15.1, for support checks
tones?: true; // accepts a skin tone modifier
shortcodes: string[]; // one product-wide pack, e.g. ["+1", "thumbsup"]
};
// emoji-es.json: per locale, loaded on demand
type EmojiWords = {
[id: string]: { name: string; keywords: string[] };
};
Decision How should the picker load and keep its emoji data?
What we pick: Use lazy per-locale JSON for the first version, with an IndexedDB cache layered on top once the picker opens many times per session or memory matters. That's what emoji-picker-element does: it keeps the data in IndexedDB, uses stale-while-revalidate, and checks the ETag with a HEAD request so it doesn't re-download unchanged data. Keep native glyphs and use images only for custom emoji.
On slow 4G with a phone: bundled JS first open 4.5 s, repeat 192 ms, 830 KB; lazy JSON first open 556 ms, repeat 76 ms, 70 KB; IndexedDB first open 656 ms, repeat 24 ms, 70 KB; sprite sheet first open 8.7 s, repeat 176 ms, 1670 KB.
The lab's numbers are estimates from a simple model, but the shape holds. Bundling ships every locale to everyone and pays parse and index work on every page load. IndexedDB costs a little more than lazy JSON on the first open, because it writes the rows, and is the fastest on every open after. In a chat app where the picker opens many times a day, repeat opens dominate.
Check your understanding · Exercise 2
Which choices make the emoji data smaller or cheaper without losing features? Select all that apply.
Emoji the device can't draw
Native emoji come from the OS font: Apple Color Emoji on macOS and iOS, Segoe UI Emoji on Windows, Noto Color Emoji on Android and most Linux. Each OS adds new emoji on its own schedule, often months after Unicode publishes them, and older devices never get them. A user on an Android phone from 2021 may be missing a few hundred emoji that everyone else sees.
The naive picker shows all of them anyway. On that phone, the user scrolls into rows of empty boxes, or picks woman firefighter and sends a woman next to a fire engine. Two failure shapes, both bad.
- Tofu: a single code point the font lacks. The browser draws a fallback box (it looks like a block of tofu) or nothing at all.
- Split sequence: every part is known but the joined sequence isn't. The browser draws each part side by side. A family of four can become four separate people.
Decision How do we avoid showing emoji the device can't draw?
What we pick: Detect on the device and hide what it can't draw, keeping native glyphs. It's what emoji-picker-element does by default, and it costs a few milliseconds once per version check. Products that need one look everywhere (Discord and Slack replace emoji with images in messages) pay for images on purpose; for a picker inside a chat that already shows native text, that cost isn't worth it. As a fallback for Windows flags, ship a tiny flag-only font or images for the flags category only.
How the canvas check works
The idea: a supported emoji draws as one colorful glyph about one em wide. Tofu draws as a gray box or nothing, and a split sequence draws two or more glyphs side by side, so it's about twice as wide. We can measure both.
const canvas = new OffscreenCanvas(32, 32);
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
ctx.font = '24px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif';
const oneGlyph = ctx.measureText("\u{1F600}").width; // grinning face, from 2010
function canDraw(emoji: string): boolean {
// A split ZWJ sequence draws two or more glyphs side by side.
if (ctx.measureText(emoji).width > oneGlyph * 1.5) return false;
ctx.clearRect(0, 0, 32, 32);
ctx.fillText(emoji, 0, 26);
const pixels = ctx.getImageData(0, 0, 32, 32).data;
// Tofu is gray: look for any pixel with real color.
for (let i = 0; i < pixels.length; i += 4) {
const [r, g, b, a] = [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]];
if (a > 0 && (Math.abs(r - g) > 30 || Math.abs(g - b) > 30)) return true;
}
return false;
}
Running this for all 3,972 emoji would take a few hundred milliseconds. Instead, pick one test emoji per version (for example a newly added face), walk down from the newest version, and stop at the first one that draws. Every emoji with a higher version number is hidden. That's 1 to 5 draws, under 5 ms, and the result is stored in localStorage keyed by the user agent string, so it reruns only after a browser or OS update.
Edge cases worth naming
- Received emoji: hiding an emoji from the picker doesn't stop others from sending it to you. The message view needs its own fallback (an image for unknown sequences) if you care. That belongs to the message view (the discord guide covers rendering messages); mention it.
- Skin tones on new emoji: a device can support a base emoji but not its toned variants (multi-person tones arrived years after the base). Test a toned variant per version too, and fall back to the base emoji.
- Custom emoji font: if the product ships its own emoji font, skip detection and set the supported version from the font's version.
Check your understanding · Exercise 3
A user on an older laptop reports that the family emoji they picked showed up as four separate people. Which statements are true? Select all that apply.
Rendering thousands of cells fast
Our picker is about 350 px wide and 420 px tall. With 36 px cells that's 9 columns, and about 9 rows fit in the scroll area. So the user sees around 80 emoji at a time, out of roughly 1,900 in the grid after support filtering (a toned variant shares its base emoji's cell), plus custom emoji (a big Slack workspace can have thousands).
The naive grid renders all of them: about 1,900 buttons with labels. On a mid-range phone that's well over 100 ms of render, style, and layout on every open, and thousands of DOM nodes held in memory while the popup is up. Since the picker opens from every add-reaction button, that cost repeats all day.
Decision How do we render a grid of thousands of emoji without a slow open?
What we pick: Virtualize by rows. Rows have a fixed height, so this is the easy case of virtualization: no measuring, and offsets are known before render. Everything the virtualized-list guide covers applies; here we only need the emoji-specific parts: header rows, sticky headers, tab tracking, and keyboard focus across virtual rows.
Flattening categories into rows
The grid becomes a flat array of rows. A header row is 32 px, an emoji row 36 px. Smileys and emotion has about 170 emoji, so 19 rows, and the last row is only partly full. Uneven last rows matter later for arrow keys.
type Row =
| { kind: "header"; group: number; top: number }
| { kind: "emoji"; group: number; ids: string[]; top: number };
function buildRows(groups: { id: number; ids: string[] }[], columns = 9): Row[] {
const rows: Row[] = [];
let top = 0;
for (const g of groups) {
rows.push({ kind: "header", group: g.id, top });
top += 32;
for (let i = 0; i < g.ids.length; i += columns) {
rows.push({ kind: "emoji", group: g.id, ids: g.ids.slice(i, i + columns), top });
top += 36;
}
}
return rows; // total height = top
}
To find the first visible row for a scrollTop, binary search the top values: about 8 steps for the roughly 230 rows. Rebuild the rows only when the column count changes (resize) or the data changes (search results, a new custom emoji), not on scroll.
Sticky headers and category tabs
A sticky header keeps the current category name pinned at the top while you scroll through it. With real DOM, position: sticky on each header does it. With virtualization, the header row may already be unmounted when its category is still on screen.
The fix: render one extra header in a pinned layer above the list, showing the category of the first visible row. When the next header row reaches the top, it pushes the pinned one up by the overlap, so it looks like real sticky headers.
The category tabs need two things. Clicking a tab sets scrollTop to that category's header top, then puts the active cell on its first emoji. And the tab for the category in view must highlight.
With real DOM sections, an IntersectionObserver with a thin rootMargin band at the top of the list tells you which section crossed it. With virtual rows, many sections don't exist in the DOM, so the observer misses them.
Compute it from data instead: the category of the first visible row, from the same binary search, on each scroll frame. It's cheaper and never wrong.
Native glyphs are just text, so each cell is a button with the character inside and a font-size of about 28 px. That's cheaper than images: no network, no decode. Custom emoji are images; give them loading="lazy", fixed width and height so rows never shift, and serve small (64 px) WebP versions from the CDN.
Check your understanding · Exercise 4
The picker virtualizes rows. Which approaches keep the category tab highlight correct while scrolling? Select all that apply.
Search as you type, in the user's language
Most people who use a picker every day don't browse: they type two or three letters and hit Enter. So search must answer on every keystroke within one frame, in the user's language, and put the emoji they meant first.
The naive version runs emoji.filter(e => e.name.includes(query)) over English names. Three problems: "perro" finds nothing for a Spanish user, "happy" misses the grinning face whose keywords include "happy" but whose name doesn't, and includes matches "cat" inside "vacation", ranking junk above the cat.
Decision Where should emoji search run?
What we pick: Search on the client. The data is small and already loaded, and network latency can never beat local. Start with the scan (it's fast enough on a laptop), and switch to a prefix index built at load time once phones or large custom emoji sets make the scan visible. The command-palette guide covers the general ranking ideas; below is what's specific to emoji.
Tokens and normalization
Normalize both the data and the query the same way: lowercase with toLocaleLowerCase(locale), remove accents by normalizing to NFD and dropping combining marks, so "cafe" finds "café". Split on spaces, hyphens, and underscores, so :thumbs_up:, "thumbs up", and "up" all hit. For Chinese and Japanese, where words aren't separated by spaces, Intl.Segmenter with granularity: "word" splits keywords into words. The tag-input guide covers Unicode normalization in more depth.
A query with many words is an AND: "red heart" returns emoji that have a token starting with "red" and one starting with "heart". A query that starts with a colon searches shortcodes only, because that user is a power user typing a known code.
Ranking: what comes first
| Signal | Example for "hea" | Weight |
|---|---|---|
| Exact shortcode or name match | None yet; "heart" would exactly match the red heart's shortcode. | Highest |
| Name starts with the query | "heart with arrow" | High |
| A name word starts with the query | "red heart", "face with head-bandage" | Medium |
| Only a keyword starts with it | Smiling face with heart-eyes (keyword "heart") | Low |
| How often this user picks it | The user's daily red heart | Boost, capped |
| Official emoji order | Ties keep the Unicode order | Tie-breaker |
The usage boost is the frecency score from the next chapter, capped so a favorite can't outrank an exact name match. Otherwise someone who always sends the laughing face would see it first for every query.
Search results respect the same filters as the grid: unsupported emoji stay hidden, and the user's skin tone applies. Custom emoji join the same index under their names and aliases, and an exact match on a custom name ranks like an exact shortcode.
Think first
A Portuguese user in Brazil types "thumbs". Their locale file is Brazilian Portuguese. Should it find the thumbs up?
Yes. Many people type English emoji words out of habit, and shortcodes are English anyway. Index the user's locale names and keywords, plus the shortcodes (always), plus English names as a low-weight fallback.
The user's own language still ranks first, because those matches carry more weight.
Check your understanding · Exercise 5
Which choices make emoji search both fast and correct? Select all that apply.
Skin tones and frequently used
Two small features carry a lot of personal weight. People care that the hands and faces they send look like them, and they expect their favorite emoji to be one click away. Both are user state the picker must store, apply fast, and ideally sync across devices.
The skin tone preference
Most pickers, including Slack's, emoji-mart, and emoji-picker-element, keep one global skin tone: a value from 0 (the default yellow) to 5. A small swatch button next to the search box opens a row of six choices. Pick one and every emoji that accepts a tone changes at once.
Because tones are a rule (base plus modifier), applying it costs nothing: when a cell renders, if the emoji has tones and the preference is above 0, render the toned string. A few emoji need data instead of the rule, for example two-person emoji where each person can have a different tone, so the base record can hold explicit variant strings for those.
Some people want a different tone for one emoji: a family photo reaction, or a handshake between two tones. So long press (on touch) or right click (with a mouse) on a toned emoji opens a variant popover with the six versions. Choosing one inserts it once, and the product can remember it as a per-emoji override in recents.
Where it lives: localStorage for an instant first render, plus the user's server-side preferences so a new laptop gets it too. Read local first, apply the server value when it arrives, and write both on change. If two devices change it at once, the last write wins: it's a preference, not a document.
Frequently used: frecency, not just recency
"Recently used" sounds simple: the last 24 picked, newest first. But one odd pick (a llama for a friend's birthday) pushes out the heart you send ten times a day. "Most used" has the opposite problem: last year's habit sticks forever. The fix is frecency: a score that combines frequency and recency.
Decision How should we rank the Frequently used row?
What we pick: Frecency with decay. Firefox ranks its address bar suggestions with a frecency score, and it fits emoji well: daily favorites stay put, one-offs fade within weeks. Store the score with the time it was last updated so decay is computed lazily on read, with no timers.
type Usage = { score: number; at: number }; // at = ms timestamp
const HALF_LIFE = 14 * 24 * 3600 * 1000;
function current(u: Usage, now = Date.now()): number {
return u.score * Math.pow(0.5, (now - u.at) / HALF_LIFE);
}
function recordPick(usage: Map<string, Usage>, id: string, now = Date.now()) {
const prev = usage.get(id);
usage.set(id, { score: (prev ? current(prev, now) : 0) + 1, at: now });
if (usage.size > 200) pruneLowest(usage, now); // keep storage small
}
const top = (usage: Map<string, Usage>, n = 27) =>
[...usage].sort((a, b) => current(b[1]) - current(a[1])).slice(0, n).map(([id]) => id);
Store the unified id and the exact string picked, so a toned override comes back toned. Keep up to 200 entries (about 6 KB) and show 3 rows (27 emoji). Seed it for new users with a sensible default row like thumbs up, heart, and laughing face, so the first section isn't empty.
One detail people miss: don't reorder the row while the picker is open. If picking the third emoji moved it to first place, a user reacting twice in a row would hit the wrong emoji. Recompute the order when the picker opens, not on each pick.
Check your understanding · Exercise 6
Which statements about skin tones and frequently used are right? Select all that apply.
Keyboard and screen readers
Keyboard users are the picker's fastest users: open, type "tada", Enter, done. Screen reader users depend on it entirely, because a grid of pictures means nothing without names. Both need a clear model of where focus is and what each key does.
The naive picker is a pile of button elements. Tab steps through them one by one: nearly 2,000 Tab presses to reach the flags. Typing in the search box and then reaching the results means many Tab presses too. And with virtualization, the button you tabbed to can be unmounted as it scrolls away, dropping focus to the page body.
Decision How should focus and arrow keys work between the search box and the emoji grid?
What we pick: Keep focus in the search box and use aria-activedescendant into a role="grid", with row and gridcell roles. It matches how people use the picker: type, arrow, Enter. Keep the active row mounted even when it scrolls out of the overscan window, and give every cell an id from its emoji id. When the search box isn't showing (a reaction-only compact picker), fall back to roving tabindex in the same grid. This is the same combobox model the command-palette guide uses, with a grid popup instead of a listbox because emoji move in two dimensions.
The key map
| Key | Does | Detail |
|---|---|---|
| Right, Left | Next or previous emoji, once a cell is active | Wraps across rows and categories in reading order, skipping header rows. Before the first Down, they move the text caret as in any text box. |
| Down, Up | Same column, next or previous row | The first Down makes the first emoji active. If a row is shorter (a category's last row), land on its last cell, but remember the column for the next move. |
| Home, End | Back to the search text | As in the APG combobox with a grid popup: the caret goes to the start or end of the query, so text editing keys keep working. |
| PageDown, PageUp | Move by one screen of rows | About 9 rows; same column rule as Down and Up. |
| Enter | Pick the active emoji | Shift+Enter picks and keeps the picker open for several reactions. |
| Escape | Back out one level | Close the variant popover, else clear the search text, else close the picker and return focus to the button that opened it. |
| Tab | Leave the grid | To the skin tone button, then the category tabs, then back. |
The "remember the column" rule matters. Press Down from column 7 into a last row with 3 cells and you land on cell 3. Press Down again into a full row: you should be back in column 7, not stuck in column 3. Store a preferredColumn that only Left and Right change.
Screen reader says: Focus the search box, then use the arrow keys.
- Focus the search box, then use the arrow keys.
What the screen reader says
- Each cell's accessible name is the CLDR name in the user's locale plus the tone:
aria-label="thumbs up: medium skin tone". The CLDR names are written for speech, so use them as they are. - Each category's rows sit in a
role="rowgroup"labelled by its header. Screen readers differ in announcing group changes, so the picker also says the category ("Food and drink") in its polite live region when the active cell crosses into a new one. - Search results: a polite live region says "12 results" or "No emoji found" 500 ms after typing stops, not on every key.
- Custom emoji use their name, like "party parrot", and say "custom emoji" once in the category label rather than per cell.
- Emoji in the grid are
aria-hiddentext inside a labelled cell, so screen readers don't read both the glyph and the label.
Check your understanding · Exercise 7
The search box keeps focus and points into a virtualized grid with aria-activedescendant. Which are required for this to work? Select all that apply.
Getting the emoji into the message
Picking an emoji is half the job. The other half is putting it exactly where the caret was, in a composer the picker doesn't own. The same picker also serves reactions, where nothing is inserted at all.
The naive picker reaches into the page and appends the emoji to the textarea's value. Three bugs follow: it lands at the end, not at the caret; it skips the composer's state, so a React-controlled textarea overwrites it on the next render; and because the picker took focus, the selection the user had is gone by the time Enter is pressed.
Decision Who is responsible for inserting the emoji?
What we pick: Use the onSelect callback and ship a small insertAtCaret helper for plain textareas. The picker knows emoji; the composer knows text. Keeping that boundary is what lets one component serve the composer, reactions, and the status message field.
onSelect. The picker never touches the composer's text.Keeping the caret
Save the selection when the picker opens, before focus moves. For a textarea that's selectionStart and selectionEnd. For a rich text editor it's the editor's own selection (a range in its document model), not the DOM selection, which the picker's focus will destroy. The rich-text-editor guide explains why the model's selection is the source of truth.
function insertAtCaret(el: HTMLTextAreaElement, text: string, saved: { start: number; end: number }) {
el.focus();
el.setSelectionRange(saved.start, saved.end);
// Replaces the selection (if any), puts the caret after the emoji.
el.setRangeText(text, saved.start, saved.end, "end");
// Controlled inputs (React) listen for input events to sync their state.
el.dispatchEvent(new Event("input", { bubbles: true }));
}
Two details. First, setRangeText doesn't add an entry to the browser's native undo stack in most browsers, so Ctrl+Z won't remove the emoji. Composers that care keep their own undo history. Second, if the user typed :tad and picked from inline shortcode suggestions, replace the :tad too: the saved range starts at the colon, not at the caret.
In rich text, standard emoji insert as plain text through the editor's insert-text command. Custom emoji insert as an inline node that renders an img with alt=":party_parrot:", and serializes to the shortcode in the stored message. That way copy and paste, plain text notifications, and search all see a readable name.
Check your understanding · Exercise 8
A user selects the word "great" in the composer, opens the picker, and picks the fire emoji. What should happen? Select all that apply.
The GIF tab: a third-party search service
The GIF tab is the only part of the picker that lives on the network. It shows trending GIFs when the search box is empty, and search results as the user types. It's a small search results page with heavy media, rate limits, and someone else's rules.
Provider risk is real here. For years Tenor (owned by Google) and GIPHY were the two big GIF APIs. Google stopped accepting new Tenor API sign-ups on January 13, 2026 and shut the public Tenor API down on June 30, 2026, so apps built on it, Discord and X among them, had to move to other providers. The lesson for the design: never let a provider's URL or response shape leak into the UI.
Decision Should the browser call the GIF provider directly, or go through our backend?
What we pick: Proxy through our backend with our own response shape. The Tenor shutdown is the argument: with a proxy, switching providers is one new server adapter, while apps that called Tenor from every client had to ship updates to every platform. The extra hop is small, and edge caching of trending more than pays it back.
Search requests: debounce, cancel, ignore stale
Unlike emoji search, GIF search costs a network round trip and counts against a quota. Debounce by about 300 ms so "birthday" is one request, not eight (the debounce-throttle lesson covers the timing). Then guard against request races: if the request for "bir" returns after the one for "birthday", the old results must not replace the new ones. Abort the previous request with an AbortController, and also tag each request with the query it was for and drop responses that don't match the current query.
The state machine keeps the UI honest. Idle shows trending, and typing waits for the debounce. Loading keeps the previous results on screen, dimmed, so the grid doesn't flash empty.
An error shows a retry button in the grid area, and an empty result says "No GIFs for this search" with the attribution still visible. Scrolling near the bottom fetches the next page with the cursor the API returned.
Media: never load GIF files in the grid
The GIF format is a terrible way to ship animation. GIPHY's own docs recommend MP4 or WebP renditions and say not to use the GIF format for standard content. The same clip is usually several times smaller as video: web.dev's example turns a 3.7 MB GIF into a 551 KB MP4. With 24 results per page on screen at once, that's the difference between a few hundred KB and several MB.
- Previews: a
videoelement withmuted,loop,playsinline, andautoplay, using the small MP4 rendition, with the still image asposter. Fall back to WebP in animgwhere video autoplay is blocked. - Only play what's visible: an
IntersectionObservercallsplay()when a tile enters the viewport andpause()when it leaves. Twenty playing videos offscreen burn battery for nothing. - Reduced motion: if
prefers-reduced-motion: reducematches, show stills and play only on hover or focus. - Layout: GIFs have different aspect ratios, so use a two-column masonry layout with heights computed from the width and height in the response, so nothing shifts as media loads. The Pinterest guide covers masonry placement.
- Send: when the user picks one, send a larger rendition's URL (or the GIF id) to the message, not the preview.
Rules you must follow
- Attribution: providers require visible credit, such as a "Powered by GIPHY" mark in the picker and on search. Put it in the tab's footer so it's always visible.
- Content rating: request a safe rating by default (GIPHY uses
ratingvalues likeg,pg,pg-13,r). Let workspace admins choose, never the individual request alone, and enforce it in the proxy so a modified client can't bypass it. - Locale: pass the user's language so "feliz cumpleaños" returns Spanish-language results.
- Share events: some providers ask you to report when a GIF is sent. The proxy reports it, so the client stays provider-agnostic.
Check your understanding · Exercise 9
Results for "cat" briefly replace the results for "cats dancing" after the user finished typing. Which fixes are right? Select all that apply.
Architecture, data flow, and the component API
Now put the pieces together. The picker is a lazily loaded chunk with a small set of stores and services. The boundaries follow what changes together: data loading knows nothing about the grid, the grid knows nothing about where emoji go, and the GIF client knows nothing about which provider sits behind our proxy.
| Part | Responsibility | Why it's separate |
|---|---|---|
PickerShell | Popover or bottom sheet, tabs (Emoji, GIFs), focus trap, Escape, return focus. | Same shell on desktop and phones; the content doesn't care. |
SearchBox | Holds the query, keeps focus, owns aria-activedescendant, routes keys. | One keyboard brain for emoji and GIF grids. |
EmojiGrid | Virtual rows, sticky header, category tabs, variant popover. | Pure view over a list of rows; browse and search share it. |
EmojiStore | Loads base and locale data, support filter, search index, custom emoji. | Owns caching (memory, IndexedDB) so the grid stays dumb. |
PrefsStore | Skin tone, frecency map; localStorage first, server sync after. | User state with its own persistence and merge rules. |
SupportDetector | Canvas checks, cached per user agent. | Runs once, before the first grid render. |
GifClient | Debounced search, abort, pagination, our proxy's shape. | The only network-heavy part; isolated for races and errors. |
Loading order on first open: the shell chunk (say 15 KB gzipped) loads on hover or focus of the emoji button, before the click. On click, the shell renders at once with the Frequently used row from localStorage, which needs no emoji data. The base data and locale words load in parallel, support detection runs, and the grid fills in. On later opens everything is in memory or IndexedDB, so the grid renders in the same frame.
Client data model
type PickerState = {
query: string;
tab: "emoji" | "gif";
active: { row: number; col: number; preferredCol: number } | null;
variantFor: string | null; // emoji id with the tone popover open
};
type Prefs = { // localStorage + server
skinTone: 0 | 1 | 2 | 3 | 4 | 5;
usage: Record<string, { score: number; at: number; char: string }>;
};
type CustomEmoji = { // per workspace, from the API
id: string; name: string; aliases: string[];
url: string; animated: boolean; category?: string;
};
type GifResult = { id: string; title: string; width: number; height: number;
previewMp4: string; previewStill: string; sendUrl: string };
Where each lives: emoji data in IndexedDB plus an in-memory index, preferences in localStorage and on the server, custom emoji in the app's server cache (refetched when a workspace event says the set changed), picker state in component state, and GIF results in a small per-query memory cache so going back to an earlier query is instant.
Check your understanding · Exercise 10
Why does the Frequently used row render before the emoji data has loaded? Select all that apply.
The component API and the GIF endpoint
The picker is a design system component, so its API is the interface to design. Keep it small: callers pass context in and get a rich selection out. Everything about loading and caching stays inside.
type EmojiSelection =
| { kind: "emoji"; char: string; id: string; tone: number;
shortcode: string; name: string } // name in the UI locale
| { kind: "custom"; id: string; name: string; url: string }
| { kind: "gif"; id: string; sendUrl: string; title: string };
type EmojiPickerProps = {
onSelect: (s: EmojiSelection, how: { keepOpen: boolean }) => void;
onClose: () => void;
locale: string; // "es", "pt-BR"; drives words and search
tabs?: ("emoji" | "gif")[]; // reactions pass ["emoji"]
categories?: string[]; // order and subset, e.g. no flags
customEmoji?: CustomEmoji[]; // workspace set, shown and searched
theme?: "light" | "dark" | "auto";
dataUrl?: string; // self-host the emoji JSON
autoFocus?: boolean; // false for reaction-only compact use
};
The selection carries the native string for inserting, the unified id for storage and reactions (stable even if rendering changes), the shortcode for plain text exports, and the localized name for toasts like "You reacted with red heart". Custom emoji come back with their workspace id, so a reaction refers to the image by id, not by URL.
GET /api/gifs/search?q=cats%20dancing&locale=es&cursor=24&limit=24
200 OK
Cache-Control: private, max-age=300
{
"results": [
{ "id": "g_8sd7", "title": "Cat dancing", "width": 200, "height": 150,
"previewMp4": "https://media.example/g_8sd7/200w.mp4",
"previewStill": "https://media.example/g_8sd7/200w.webp",
"sendUrl": "https://media.example/g_8sd7/480w.mp4" }
],
"next": "48",
"attribution": "giphy"
}
Errors use the app's standard shape. A 429 from our own rate limit carries Retry-After, and the GIF tab shows "Too many searches, try again in a moment" instead of retrying in a loop. If the provider is down, the proxy returns 503 and the tab shows the error state with retry while the emoji tab keeps working.
Check your understanding · Exercise 11
A reaction stores which emoji was used. What should it store? Select all that apply.
Running it in the interview
Forty-five minutes go fast. Start with requirements and the two forces that set this problem apart (emoji are sequences, the device decides what draws), then draw the wireframe and the architecture. Spend most of the time on two or three deep dives the interviewer picks.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 6 | Scope questions | Reactions and composer? Locales? Native or images? GIFs? Custom emoji? |
| 6 to 12 | Wireframe and architecture | Shell, search, grid, stores, GIF client, lazy chunk. |
| 12 to 22 | Data and support | Sequences, per-locale words, IndexedDB, canvas detection. |
| 22 to 32 | Grid and keyboard | Virtual rows, tabs from offsets, active descendant, key map. |
| 32 to 40 | Search, recents, GIF tab | Local index, frecency, debounce plus abort, proxy. |
| 40 to 45 | Trade-offs and follow-ups | What you'd measure and change at 10 times the scale. |
Say trade-offs out loud as you go: "I'm keeping native glyphs, which costs support detection and gains zero image bytes." Interviewers score the reasoning more than the choice.
Think first
Follow-up: "A Discord-style server has 2,000 animated custom emoji. What changes?"
Custom emoji become most of the grid, so virtualization is mandatory and images must be cheap: 64 px WebP stills in the grid, animated versions only on hover or for the active cell, and loading="lazy" with fixed sizes.
Search joins custom names and aliases into the same index. The set is fetched per server and cached with a version, refreshed when a server event says it changed. Respect reduced motion for animated ones. The discord guide covers how the same emoji render inside messages.
Think first
Follow-up: "How do you know the picker is fast for real users?"
Measure from click to first grid paint with performance.mark and send it with the device class, split into first open and repeat open. Track the share of opens where data came from network versus IndexedDB, search latency per keystroke, and Interaction to Next Paint on the emoji button.
Set budgets, such as repeat open under 50 ms at the 75th percentile, and alert when a data update or a new feature pushes past them.
Summary
- An emoji is often a sequence of code points (skin tone modifiers, zero width joiners, variation selectors). Store, count, and insert emoji as whole strings, and key them by unified id.
- Split the data: a small base file for the grid, keywords per locale for search, skin tones as a rule. Load lazily and cache in IndexedDB with stale-while-revalidate so repeat opens cost nothing.
- Native glyphs need support detection: a canvas check per emoji version catches tofu (color) and split sequences (width). Images give one look everywhere at a cost of megabytes.
- Virtualize by fixed-height rows with header rows; drive sticky headers and the active tab from row offsets, not from observers on elements that may be unmounted.
- Search runs on the client over normalized tokens from CLDR names, keywords, and shortcodes, ranked by match type with a capped frecency boost.
- Keep focus in the search box and point into a
role="grid"witharia-activedescendant; remember the column across uneven rows, name every cell, and return focus on close. - Hand back a rich selection through
onSelectand let the composer insert at its saved selection; the picker knows emoji, the composer knows text. - Put GIF providers behind your own proxy and response shape: debounce, abort, drop stale responses, MP4 previews that play only when visible, rating enforced on the server, attribution always shown.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Unicode Technical Standard #51: Unicode Emoji Sequences, modifiers, ZWJ, and fully-qualified emoji.
- Unicode CLDR: emoji annotations (names and keywords per locale) Where localized emoji names and keywords come from.
- emoji-picker-element (README: data loading, IndexedDB, ETag) A real picker built around IndexedDB and native emoji with support detection.
- Nolan Lawson: The struggle of using native emoji on the web Why support detection is needed and where it falls short.
- WAI-ARIA APG: Combobox pattern (with a grid popup example) Focus in the input,
aria-activedescendantinto the popup. - WAI-ARIA APG: Editable combobox with grid popup example The key map for a text box that drives a grid with
aria-activedescendant. - Unicode: Emoji counts How many emoji of each kind (single code points, tone sequences, ZWJ sequences) the current version has.
- web.dev: Replace animated GIFs with video Why muted, looping video beats GIF files, with byte comparisons.
- WAI-ARIA APG: Grid pattern Keyboard model for two-dimensional grids.
- MDN: Intl.Segmenter Counting graphemes and splitting words in any language.
- GIPHY developer docs: renditions and optional settings Why MP4 and WebP previews beat GIF files in a grid.
- jdecked/twemoji The community-maintained continuation of Twemoji.
Concepts behind it
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.