cf.completefrontend
My progress
SYSTEM DESIGN · #38

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.

QuestionAnswer we assumeWhat 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.
Anatomy of the emoji picker. A popup has two tabs at the top, Emoji (selected) and GIFs. Below them a search box holds the text hea with a skin tone swatch to its right. A row of eight category tabs follows, with Smileys highlighted. A sticky header reads Smileys and emotion over a grid of three rows of seven emoji cells; the second cell of the first row, face with tears of joy, is the active cell. At the bottom a preview shows its name and the shortcode :joy:. Notes on the right name each part: tabs, a search box that keeps focus, category tabs that track the scroll, virtual rows with a sticky header and an active cell, and the preview.emoji pickerEmojiGIFshea|tonerecsmipplnatfoodtrvobjsymSmileys and emotiongrinjoywinkhugcoolloveyumsadcrywowzzzsickhotcoldcatdogfoxowlbeepigcowface with tears of joy :joy:tabs: emojiand GIFssearch keeps focus,tone swatchcategory tabstrack the scrollvirtual rows, stickyheader, active cellpreview: nameand shortcode
Every part of the picker in one popup. Focus never leaves the search box: arrow keys move the active cell in the grid, and the preview names it for sighted keyboard users.

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?

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+1F3FB to U+1F3FF, based on the Fitzpatrick scale. Put one after a person or hand and it changes the skin tone: thumbs up plus U+1F3FD is 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+FE0F asks for the colorful emoji style. The heart U+2764 is older than emoji and draws as a black text symbol without it. Data must store the fully-qualified form, with U+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.
Three emoji built from code points. Woman firefighter with medium skin tone is four code points: woman 1F469, the skin tone modifier 1F3FD, the zero width joiner 200D, and fire engine 1F692; it draws as one glyph but has a JavaScript length of 7. Red heart is 2764 followed by variation selector 16, FE0F, which asks for the color style; length 2. The United States flag is two regional indicator letters, 1F1FA and 1F1F8; Windows shows the two letters instead of a flag.woman firefighter: medium skin tone1F469woman1F3FDmedium tone200DZWJ glue1F692fire engineone glyphJS length 7red heart2764heartFE0FVS16: colorcolor heartJS length 2flag: United States1F1FAletter U1F1F8letter SflagWindows: U S
What looks like one character is often a sequence. Store and insert the whole string, count with 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.

ts
// 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[] };
};
The base record (shared by all locales) and the per-locale words, loaded separately.

Decision How should the picker load and keep its emoji data?

Bundle the JSON into the picker's JavaScript

Import the data file, so the bundler puts it into the picker chunk.

It's parsed every time the chunk runs.

Pros

  • One request, simplest code.
  • Works offline once the chunk is cached.

Cons

  • Every locale's words ship to everyone, or you build 20 chunks.
  • JavaScript parses slower than JSON.parse, and the whole object lives in memory all session.
  • Any emoji update invalidates the code chunk too.

Lazy JSON per locale, kept in memory

On first open, fetch the base file and the user's locale file in parallel from a CDN with long Cache-Control and hashed names.

Parse once, build the index, keep it in a module variable.

Pros

  • Pays only for the user's locale.
  • The HTTP cache makes the next page load cheap.
  • Simple to reason about.

Cons

  • Each new page load still reads the file from the HTTP cache, parses it, and rebuilds the index: tens of milliseconds on a phone.
  • The full data sits in memory for the whole session.

IndexedDB cache with stale-while-revalidate

First open: fetch the JSON, write rows into IndexedDB with indexes on group and on keyword tokens.

Later opens: read only the rows needed (one category, one search) straight from IndexedDB.

In the background, check whether the data changed (an ETag and a HEAD request) and update if so.

Pros

  • Repeat opens skip download and parse entirely.
  • Low memory: only visible rows are loaded.
  • Works offline from the second visit.

Cons

  • First open is slower: download plus the write.
  • IndexedDB is async and has rough edges (some private modes, quota, schema upgrades).
  • More code to own.

Sprite sheet images plus a small index

Draw every emoji in one or a few large PNG or WebP sprite sheets (for example from Twemoji).

Each cell is a background position. The JSON holds only ids and words.

Pros

  • Looks the same on every device; no support detection.
  • One image request covers the whole grid.

Cons

  • Sheets for every emoji at 2x resolution can weigh a few MB, more than all the text data.
  • Inserted emoji are still native text in the message, so picker and message may look different.
  • Must keep up with new Unicode versions yourself.

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.

Network
Device

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.

Data loading lab on slow 4G with a phone. bundled JS: first open 4.5 s, repeat open 192 ms, first download 830 KB. lazy JSON: first open 556 ms, repeat open 76 ms, first download 70 KB. IndexedDB: first open 656 ms, repeat open 24 ms, first download 70 KB. sprite sheet: first open 8.7 s, repeat open 176 ms, first download 1670 KB.first openrepeat, new pagefirst downloadbundled JS4.5 s192 ms830 KBlazy JSON556 ms76 ms70 KBIndexedDB656 ms24 ms70 KBsprite sheet8.7 s176 ms1670 KBIndexedDB repeat: a HEAD request checks the ETag in the background
Estimates from a simple model: round trip plus download, plus parse, index, and storage work scaled by device speed. Green is best in its column, red worst. The bundle holds all 20 locales; the sprite sheet is 2x images.

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?

Show everything, trust the OS

Render every emoji in the data as native text.

Pros

  • Zero work, zero cost.

Cons

  • Tofu and split sequences in the grid, and users unknowingly send them.
  • Looks broken on older devices, which are a real share of phones.

Detect support on the device with a canvas

Draw a test emoji into a small offscreen canvas and read the pixels or measure its width.

Test one emoji per Unicode version to find the newest supported version, then hide newer emoji.

Pros

  • Native look and native size: no image downloads.
  • Correct per device, including updates.
  • A handful of draws: a few milliseconds, cached.

Cons

  • Heuristic: fonts can partly support a version.
  • Needs a canvas (not in some locked-down or test environments).
  • Users on old devices see fewer emoji than their friends.

Render images for every emoji (Twemoji, Noto, or your own)

Draw each cell from a sprite sheet or one small SVG per emoji.

Also replace emoji in messages with the same images so they match.

Pros

  • Every emoji works on every device.
  • Same look everywhere, which matters for brand and for screenshots.

Cons

  • Megabytes of images, and an image per emoji in every message.
  • You own updates to new Unicode versions. Twitter stopped updating Twemoji; a community fork (jdecked/twemoji) carries it on.
  • Copying text gives the native character, which may then render differently.

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.

ts
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;
}
One check per emoji version: if the version's newest emoji draws as one glyph, the version is supported.

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.

Support detection flow. Step one tests one emoji from the newest version, 18.0. Step two draws it on an offscreen canvas, measuring its width and reading its pixels. Step three asks whether it drew as one colored glyph. If no, try the next older version (17.0, 16.0, and so on) and loop back. If yes, that version is supported: hide every emoji from newer versions, and cache the result keyed by user agent. Below, three outcomes: one glyph means supported, a gray tofu box means a missing code point, and two glyphs side by side mean a split ZWJ sequence.test v18 emojinewest firstdraw on canvasmeasure, read pixelsone color glyph?width and colortry older versionv17, v16, ...cache resultkey: user agentsupported: v16hide newer emojinoyesone glyphtofu boxtwo glyphssupportedmissing code pointsplit ZWJ sequence
One to five canvas draws per device, cached until the browser or OS updates. Width catches split sequences; color catches tofu.

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?

Render everything

One button per emoji, all categories in the DOM.

Pros

  • Simplest code. Browser find-in-page, scroll anchoring, and section observers all just work.

Cons

  • Slow first paint of the popup (100+ ms on phones).
  • Memory and style recalculation on every theme or skin tone change.

Render everything, skip offscreen work with content-visibility: auto

Wrap each category in a section with content-visibility: auto and contain-intrinsic-size set to its known height.

The browser skips layout and paint for sections that are offscreen.

Pros

  • Almost no code. Real DOM keeps semantics and IntersectionObserver simple.
  • Supported in current Chrome, Edge, Firefox, and Safari.

Cons

  • Still creates every node, so render and memory costs stay.
  • Helps paint and layout, not the React render of 1,900 cells.

Virtualize by rows

Flatten the data into rows: one header row per category, then rows of 9 emoji.

Every row has a known height, so offsets are a prefix sum.

Render only the rows in view plus a few rows of overscan.

Pros

  • About 15 rows, so roughly 135 buttons, whatever the data size.
  • Opens in well under one frame of render work; custom emoji sets of any size are fine.

Cons

  • Find-in-page can't see offscreen emoji (fine: we have search).
  • Sticky headers, tab tracking, and keyboard focus need extra care, covered below.

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.

Virtualized emoji grid. On the left, a slice of the flat row list: the Smileys header, Smileys rows 18 and 19, the People header, then People rows 1 to 8. People rows 1 to 5 are in the viewport; Smileys row 19 through People row 7 are rendered, including overscan; the rest exist only as data. On the right, four steps: read scrollTop, 888 px (the Frequently used header and 3 rows, then the Smileys header and 19 rows); binary search the row tops to find the first visible row, People row 1; render Smileys row 19 to People row 7, about 70 buttons; pin the People header and highlight the People tab.flat rows (data)Smileys headerSmileys row 18Smileys row 19People headerPeople row 1People row 2People row 3People row 4People row 5People row 6People row 7People row 8viewscrollTop 888 pxread once per framebinary search row topsfirst visible: People row 1render Smileys 19 to People 7view plus overscan, about 70 buttonspin People headerhighlight the People tab
Rows have fixed heights, so the first visible row comes from a binary search over known offsets. The pinned header and the active tab come from that same row, not from observers on elements that may be unmounted.

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.

ts
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.

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.

Skin tone sequence with five actors: User, Grid, Popover, Prefs, and Server. The user long presses thumbs up; the grid opens a popover with six tones; the user picks medium; the popover calls onSelect with thumbs up, medium, for this one pick only. Later the user picks medium, tone 3, in the global swatch; the grid sets the tone to 3 in Prefs; Prefs saves to localStorage and the grid re-renders the toned cells at once, then Prefs sends PUT prefs to the server. The server fails with a 500; Prefs keeps the local value and retries later.UserGridPopoverPrefsServerlong pressopen 6 tonespick mediumonSelect +1:3swatch: tone 3tone = 3save, re-renderPUT prefs500, retry later
A per-emoji choice changes one pick; the swatch changes the preference. The UI updates from the local copy first, so a failed sync never undoes what the user sees.

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?

Most recent first

Keep a list of the last N picks without duplicates; move a picked emoji to the front.

Pros

  • Predictable: the thing you just used is first.
  • Tiny state.

Cons

  • A one-off pick evicts a daily favorite.
  • The row reshuffles constantly, so muscle memory never forms.

Count of uses

Keep a counter per emoji; show the top N.

Pros

  • Stable order; daily favorites stay.

Cons

  • New habits take weeks to surface.
  • Old habits never leave.

Frecency with decay

Each pick adds 1 to the emoji's score after decaying the old score by its age, for example halving every 14 days.

Show the top N by current score.

Pros

  • Daily favorites stay, one-offs fade, new habits rise in days.
  • One number per emoji: easy to store and merge across devices.

Cons

  • Order is less predictable than "most recent".
  • One tuning knob (the half-life) to pick.

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.

ts
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);
Lazy exponential decay: no background job, and two devices merge by taking the higher decayed score.

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?

Roving tabindex in the grid

The grid is one Tab stop. The active cell has tabindex="0", all others -1.

Arrow keys move real DOM focus between cells.

Pros

  • Real focus: every screen reader follows it reliably.
  • A well-known pattern (see the keyboard-navigation lesson).

Cons

  • Typing a letter while in the grid does nothing unless you send focus back to search.
  • Virtualization must keep the focused cell mounted, or focus is lost.

Focus stays in search; aria-activedescendant points into the grid

The search input keeps DOM focus. It's a combobox whose popup is the grid (aria-haspopup="grid", aria-controls).

Arrow keys change which cell is active; the input's aria-activedescendant holds that cell's id.

Pros

  • Typing always works: the user can type, arrow, type again.
  • The pattern the APG documents as a combobox with a grid popup.

Cons

  • The active cell must exist in the DOM with a stable id, so the virtual list must keep it mounted.
  • Screen reader support for active descendant inside grids is a little less consistent than real focus; test with VoiceOver and NVDA.

role="listbox" with options instead of a grid

Treat the emoji as one long list of options; Left and Right move by one, Up and Down by one row in our own handler.

Pros

  • Listbox is the most widely supported popup for comboboxes.
  • Screen readers announce "3 of 170" positions.

Cons

  • A listbox is one-dimensional, so Up and Down moving by 9 contradicts what the role tells assistive tech.
  • No way to express rows or column positions.

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

KeyDoesDetail
Right, LeftNext or previous emoji, once a cell is activeWraps 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, UpSame column, next or previous rowThe 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, EndBack to the search textAs 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, PageUpMove by one screen of rowsAbout 9 rows; same column rule as Down and Up.
EnterPick the active emojiShift+Enter picks and keeps the picker open for several reactions.
EscapeBack out one levelClose the variant popover, else clear the search text, else close the picker and return focus to the button that opened it.
TabLeave the gridTo 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.

Keys
Skin tone
Up and Down

Screen reader says: Focus the search box, then use the arrow keys.

Keyboard lab grid with 4 categories and 6 columns. Active cell: thumbs up in Frequently used. Preferred column 1. Skin tone default.Frequently used+1heartjoyfireSmileys and emotiongrinsmilewinkhalocoolloveyumthinksleepPeople and bodywaveclapprayokflexpencookrunAnimals and naturecatdogfoxowlbeerow 1, col 1preferred col 1Enter inserts,Escape clears,then closes
Rows end unevenly at each category. With column memory on, Down from column 6 into a short row lands on its last cell, then returns to column 6 on the next full row. PageDown moves 3 rows in this small grid.
  1. 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-hidden text 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?

The picker inserts into a target element

Pass a ref to the textarea; the picker edits its value at the saved selection.

Pros

  • Callers write no code for the common case.

Cons

  • Only works for plain textareas, not rich text editors or reactions.
  • Bypasses the caller's state and undo.

The picker calls onSelect; the caller inserts

The picker hands back a selection object: the string, the unified id, the shortcode, whether it's custom.

The composer inserts it with its own editing API; the reaction button sends it.

Pros

  • One picker for textareas, rich text, and reactions.
  • Each caller keeps its own undo, state, and formatting rules.

Cons

  • Each caller writes a few lines of insert code; ship a helper for textareas.

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.

Insert sequence with four actors: User, Composer, Picker, and Prefs. The user clicks the emoji button; the composer saves its selection, characters 4 to 9, and opens the picker. The user types fi and presses Enter. The picker records the pick in Prefs for frecency, then calls onSelect with fire. The composer inserts the emoji at 4 to 9, fires an input event so its state updates, and re-renders. The picker closes and focus returns to the composer after the emoji.UserComposerPickerPrefsclick emoji buttonsave 4..9, opentype fi, EnterrecordPick(fire)onSelect(fire)insert, re-renderclose, focus back
The composer saves its selection before the picker takes focus, and does the insert itself through 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.

ts
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 }));
}
Insert at the saved selection in a textarea, then tell the framework the value changed.

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?

Browser calls the provider directly

Ship a public API key restricted to search; call the provider's search endpoint from the client.

Pros

  • No backend work; lowest latency for the first version.

Cons

  • The key is public, so anyone can burn our quota.
  • Switching providers means a client release.
  • The provider sees every user's IP and queries.

Our backend proxies a normalized GIF API

The client calls /api/gifs/search?q=&cursor=; the backend calls the provider with a secret key and returns our own shape.

Trending and popular queries are cached at the edge for a few minutes.

Pros

  • Key stays secret; per-user rate limits are ours.
  • Provider swap is a server change; can even blend providers.
  • Edge cache absorbs trending and common queries.

Cons

  • One more hop (10 to 50 ms) and a service to run.
  • Must still pass through attribution and analytics events the provider requires.

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.

GIF tab state machine. Idle shows trending. Typing moves to debouncing. After 300 ms without typing it moves to loading. Loading ends in results on success, empty when there are zero results, or error on failure. A note says: typing in any state aborts the request and goes back to debouncing; retry moves from error to loading; reaching the end of the results loads the next page.idle: trendingdebouncingloadingerror, retryemptyresultstype300 msok0 resultsfailtyping in any state: abort, debounceretry: error back to loadingend of results: load next pageloading keeps old results, dimmed
Loading keeps the previous results on screen, dimmed. Every new keystroke aborts the request in flight, so a slow old response can never land on top of a new one.

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.

GIF search sequence with four actors: User, GifTab, Proxy, and GIPHY. The user types cats; after the debounce the tab sends search cats to our proxy. The user keeps typing cats dancing; the tab aborts the cats request and sends a new search. The proxy calls GIPHY with a pg rating. GIPHY answers 429, rate limited. The proxy returns 503 with Retry-After to the tab. The tab keeps the old results on screen and shows a retry button.UserGifTabProxyGIPHYtypes catssearch catscats dancingabort, search againsearch, rating pg429 rate limited503, Retry-Afterold results + retry
The failure path: the provider's limit becomes our own error shape, and the tab degrades to old results plus retry. The emoji tab keeps working because it never touches the network.

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 video element with muted, loop, playsinline, and autoplay, using the small MP4 rendition, with the still image as poster. Fall back to WebP in an img where video autoplay is blocked.
  • Only play what's visible: an IntersectionObserver calls play() when a tile enters the viewport and pause() when it leaves. Twenty playing videos offscreen burn battery for nothing.
  • Reduced motion: if prefers-reduced-motion: reduce matches, 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 rating values like g, 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.

Architecture of the picker in three layers. The picker UI, a lazy chunk, holds SearchBox, PickerShell, EmojiGrid, and GifTab. Below, stores and services: EmojiStore under SearchBox, SupportCheck under PickerShell, PrefsStore under EmojiGrid, and GifClient under GifTab, each fed by the part above it. At the bottom, storage and network: the emoji CDN serving JSON with an ETag, IndexedDB holding rows and indexes, localStorage holding tone and recents, and our GIF proxy, which calls GIPHY. EmojiStore reads the CDN and IndexedDB, PrefsStore reads localStorage, and GifClient calls the proxy.Picker UI (lazy chunk)SearchBoxPickerShellEmojiGridGifTabStores and servicesEmojiStoreSupportCheckPrefsStoreGifClientStorage, networkemoji CDNJSON, ETagIndexedDBrows, indexeslocalStoragetone, recentsGIF proxyour API shapeGIPHYprefs also sync to the API
The UI only reads from stores. Caching lives in EmojiStore, preferences in PrefsStore, and the GIF provider sits behind our proxy, so swapping it touches no client code.
PartResponsibilityWhy it's separate
PickerShellPopover or bottom sheet, tabs (Emoji, GIFs), focus trap, Escape, return focus.Same shell on desktop and phones; the content doesn't care.
SearchBoxHolds the query, keeps focus, owns aria-activedescendant, routes keys.One keyboard brain for emoji and GIF grids.
EmojiGridVirtual rows, sticky header, category tabs, variant popover.Pure view over a list of rows; browse and search share it.
EmojiStoreLoads base and locale data, support filter, search index, custom emoji.Owns caching (memory, IndexedDB) so the grid stays dumb.
PrefsStoreSkin tone, frecency map; localStorage first, server sync after.User state with its own persistence and merge rules.
SupportDetectorCanvas checks, cached per user agent.Runs once, before the first grid render.
GifClientDebounced 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.

Data flow for the emoji tab. 1: the emoji CDN sends the base and Spanish JSON, about 70 KB gzipped, to EmojiStore. 2: EmojiStore writes rows once to IndexedDB and reads them on later opens. 3: EmojiStore gives EmojiGrid the visible rows, toned and filtered. 4: PrefsStore gives EmojiGrid the skin tone and frecency order. 5: EmojiGrid calls onSelect on the Composer with the character, id, and shortcode. 6: the Composer sends the message text with the emoji to the chat API. 7: PrefsStore keeps an instant local copy in localStorage. 8: PrefsStore syncs with the prefs API, last write wins.emoji CDNEmojiStoreEmojiGridComposerIndexedDBlocalStoragePrefsStorechat APIprefs API123456781 base + es JSON, 70 KB gzip2 rows written once to IndexedDB3 visible rows, toned, filtered4 skin tone, frecency order5 onSelect: char, id, shortcode6 message text with the emoji7 instant local copy of prefs8 sync, last write wins
Reads flow left to right into the grid; the only write that leaves the picker is the selection. Preferences are local first and synced second.

Client data model

ts
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.

ts
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.

http
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"
}
Our GIF proxy: provider-neutral shape, cursor pagination, rating enforced on the server.

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.

MinutesWhat to doWhat to draw or say
0 to 6Scope questionsReactions and composer? Locales? Native or images? GIFs? Custom emoji?
6 to 12Wireframe and architectureShell, search, grid, stores, GIF client, lazy chunk.
12 to 22Data and supportSequences, per-locale words, IndexedDB, canvas detection.
22 to 32Grid and keyboardVirtual rows, tabs from offsets, active descendant, key map.
32 to 40Search, recents, GIF tabLocal index, frecency, debounce plus abort, proxy.
40 to 45Trade-offs and follow-upsWhat 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?"

Think first

Follow-up: "How do you know the picker is fast for real users?"

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" with aria-activedescendant; remember the column across uneven rows, name every cell, and return focus on close.
  • Hand back a rich selection through onSelect and 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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next