DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

An Emoji Is Not a Character, and an Emoji Picker Is Not a Grid of Buttons

Everybody builds the emoji picker the same way. A grid of buttons, a text input, and list.filter(e => e.name.includes(query)). It looks finished in an afternoon and it is wrong in three directions: wrong about ranking, because includes has no opinion about which match is better; wrong about drawing, because hundreds of live buttons mean a layout pass you pay for on every scroll; and wrong about text, because the thing you are inserting is not a character — it is up to eleven UTF-16 code units that a human counts as one.

I built a Slack/Notion-style picker in vanilla JS, and the whole thing rests on one decision: one flat array of {char, name, keywords, group}, normalised exactly once at load, with every pixel derived from it. Categories, rail, row count, results, preview and the frequently-used row are all functions of that array.

The dataset is the component

Write the source in the most compact shape a human will actually edit — character, name, a space-separated keyword string — then expand it once into objects carrying the normalised fields and, critically, the original index.

let seq = 0;
const GROUPS = RAW.map((g, gi) => ({
  name: g.name, icon: g.icon, gi,
  items: g.rows.map(([char, name, kw]) => ({
    char, name, gi, group: g.name,
    keywords: kw ? kw.split(" ") : [],
    nname: normalize(name),        // normalised ONCE
    nkw:   normalize(kw || ""),
    i:     seq++                   // stable dataset order
  }))
}));
const EMOJI = GROUPS.flatMap(g => g.items);
Enter fullscreen mode Exit fullscreen mode

That i is not decoration. It is the last tie-break in the ranker, and the difference between a result list that is ordered and one that is merely sorted. The live dataset is 502 entries across nine groups, and adding a five hundred and third is one line in one place.

Normalise both sides, exactly once

Search bugs are almost always case and punctuation bugs. The instinct is to scatter toLowerCase() through the matcher. Do the opposite: define one normaliser and run it in exactly two places — over every name and keyword when the dataset expands, and over the query on each keystroke.

function normalize(s){
  return String(s)
    .toLowerCase()
    .replace(/[_:]+/g, " ")           // :heart_eyes: -> heart eyes
    .replace(/[^a-z0-9 +-]+/g, " ")   // keep digits, hyphen and plus
    .replace(/\s+/g, " ")
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

Keep the digits and the hyphen, because 1st place medal and heart-eyes are genuine tokens and stripping them silently deletes real matches. Turn colons and underscores into spaces so people who type :heart_eyes: out of habit land somewhere sensible. After this the matcher compares plain lowercase strings and has no idea capitals ever existed.

Tiers, not fuzzy scores

A weighted numeric score feels clever right up to the moment somebody asks why the third result beat the second, and the honest answer is that 0.62 happened to exceed 0.58. Give each entry the best tier it can reach instead:

const T_EXACT = 0, T_NAME = 1, T_KEY = 2, T_SUB = 3, T_NONE = 99;

function tierOf(e, q){
  if (!q) return T_NONE;
  if (e.nname === q) return T_EXACT;
  if ((" " + e.nname).includes(" " + q)) return T_NAME;
  if ((" " + e.nkw).includes(" " + q)) return T_KEY;
  if (e.nname.includes(q) || e.nkw.includes(q)) return T_SUB;
  return T_NONE;
}
Enter fullscreen mode Exit fullscreen mode

Four string tests, no magic constants. The word-start test is the neat one: prepend a space to both haystack and needle and "does a word in this name begin with the query" collapses to a single includes. And because the tiers are ordered, you get an invariant strong enough to assert exhaustively — no tier-2 hit can ever appear above a tier-1 hit, by construction rather than by luck.

Then the part that decides how the picker feels. Inside a tier there are dozens of equals, and leaving their order to whatever the engine's sort happens to do means the list reshuffles under your fingers and the first result — the one Enter is about to take — becomes a lottery.

hits.sort((a, b) => a.t - b.t
                 || a.e.nname.length - b.e.nname.length
                 || a.e.i - b.e.i);
Enter fullscreen mode Exit fullscreen mode

Tier first. Then name length, because the shortest name containing your query is almost always what you meant: hea puts red heart above heart on fire, both of them tier 1. Then the dataset index, which makes the order total — no freedom left for the sort to exercise, so the list cannot jitter. And the ranker reads no component state, which is what lets you hand it to a brute-force scorer and compare element by element.

The same array becomes rows

Do not hardcode nine columns, and do not ask a media query — a media query measures the viewport, and this picker is 348px wide inside a 1440px page. The only honest source is the element, so a ResizeObserver reports contentRect.width and the column count is Math.max(6, Math.min(12, Math.floor(w / CELL))). With CELL at 36, a 346px scroller measures out at nine columns. Recompute only when that number actually changes.

Then flatten everything into one list of rows — a 26px header row, a 38px emoji row — and build the cumulative offset array while you are there:

function buildLayout(groups, cols, hdrH, rowH){
  const rows = [], offsets = [], heights = [];
  let y = 0;
  const push = (row, h) => { rows.push(row); offsets.push(y); heights.push(h); y += h; };
  groups.forEach((g, gi) => {
    if (!g.items.length) return;
    push({ type: "header", gi, label: g.name }, hdrH);
    const n = Math.ceil(g.items.length / cols);
    for (let r = 0; r < n; r++)
      push({ type: "cells", gi, from: r * cols, to: Math.min(g.items.length, (r + 1) * cols) }, rowH);
  });
  return { rows, offsets, heights, total: y };
}
Enter fullscreen mode Exit fullscreen mode

Everything downstream — the window, the sticky header, the rail sync, the keyboard cursor — reads that one structure. At nine columns, the 502 entries plus the frequently-used row come to 71 rows and a canvas 2,578 pixels tall.

Binary-search the window

The viewport is 288px. Mixed row heights mean you cannot divide scrollTop by a constant — but the offsets array is strictly increasing, which is all a binary search needs.

function visibleRange(L, scrollTop, viewportH, overscan){
  const n = L.rows.length, top = scrollTop, bot = scrollTop + viewportH;
  if (!n) return { first: 0, last: -1 };
  let lo = 0, hi = n - 1, first = n;
  while (lo <= hi){                                   // first row with bottom > top
    const mid = (lo + hi) >> 1;
    if (L.offsets[mid] + L.heights[mid] > top){ first = mid; hi = mid - 1; }
    else lo = mid + 1;
  }
  if (first === n || L.offsets[first] >= bot) return { first: 0, last: -1 };
  let last = first;
  while (last + 1 < n && L.offsets[last + 1] < bot) last++;
  return { first: Math.max(0, first - overscan), last: Math.min(n - 1, last + overscan) };
}
Enter fullscreen mode Exit fullscreen mode

Log time to the first row, then a short walk to the last, because the last is always a handful of rows away. The canvas keeps its full computed height so the scrollbar tells the truth, every visible row is absolutely positioned at its offset, and two overscan rows on each side mean a fast flick never shows a hole. The DOM holds about a dozen rows out of 71 — and the read-out prints both numbers side by side, so the gap is visible rather than claimed.

The same array pays for the chrome too. With rows absolutely positioned, position: sticky has nothing to stick to, so stickyRowAt walks the rows and returns the last header whose offset is at or above scrollTop. The push effect — outgoing header nudged out of frame by the incoming one — is one subtraction against that header's 26px height. The rail reads the same index to light its chip, and clicking a chip sets scrollTop to that header's offset: one source of truth, both directions.

An emoji is a grapheme, not a character

The family emoji gives three different answers to what looks like one question. Its .length is 11, because JavaScript strings are UTF-16 code units and four of those code points need surrogate pairs. Spreading it gives 7, the real code points. Intl.Segmenter gives 1, which is what a human means by a character and what one backspace should delete.

Slice that with a numeric string index and you cut a surrogate pair in half and render a replacement glyph. So build the inspector before you build anything else:

const SEG = new Intl.Segmenter(undefined, { granularity: "grapheme" });

function inspect(str){
  const cps = [...str].map(c => c.codePointAt(0));   // NOT str.split("")
  return {
    codePoints: cps,
    units:     str.length,
    graphemes: [...SEG.segment(str)].length,
    hasZWJ:    cps.includes(0x200D),
    hasVS16:   cps.includes(0xFE0F)
  };
}
Enter fullscreen mode Exit fullscreen mode

Annotate the output and two things stop being folklore. VS16, U+FE0F, is why the red heart is two code points while the bare heart is one: a zero-width, invisible, load-bearing switch between the monochrome text glyph and the colour emoji, and the reason two visually identical strings can fail an equality check. ZWJ, U+200D, is why the family is seven — man, joiner, woman, joiner, girl, joiner, boy — welded into one picture by a font that knows the combination, and falling apart into four separate people on one that does not.

Skin tones: insert after the base, drop the VS16

A skin tone is not a suffix. The five Fitzpatrick modifiers U+1F3FBU+1F3FF are combining characters that must directly follow the code point they modify. For a bare waving hand that is position zero; for the technologist — person, joiner, laptop — the base is the person, so the modifier belongs before the joiner. Append it to the end instead and you get a person followed by a tinted laptop. You need no hand-maintained table of which emoji accept a tone either, because JavaScript regexes expose the Unicode property directly:

const TONES = [0x1F3FB, 0x1F3FC, 0x1F3FD, 0x1F3FE, 0x1F3FF];
const MODBASE = /\p{Emoji_Modifier_Base}/u;

function applyTone(str, tone){
  const cps = cpsOf(stripTone(str));
  const at = modBaseIndexes(cps)[0];
  if (!tone || at === undefined) return String.fromCodePoint(...cps);
  let rest = cps.slice(at + 1);
  if (rest[0] === 0xFE0F) rest = rest.slice(1);   // VS16 implied by the modifier
  return String.fromCodePoint(...cps.slice(0, at + 1), TONES[tone - 1], ...rest);
}
Enter fullscreen mode Exit fullscreen mode

Two rules finish it. A modifier already forces emoji presentation, so a U+FE0F immediately after the base is redundant and gets dropped — which is why the victory hand goes from U+270C U+FE0F to U+270C U+1F3FD. And toneCapable offers the flyout only when modBaseIndexes finds exactly one base, so the family, with four, correctly gets none. The proof that you inserted at the right index is free: if the segmenter still reports one grapheme, the position was correct.

Frecency: a frequency that forgets

A raw counter turns the frequently-used row into a museum. The emoji you fired forty times during one launch outranks the one you have used six times today, permanently.

const HALF_LIFE = 1000 * 60 * 60 * 6;
const decayScore = (score, dt) => score * Math.pow(0.5, dt / HALF_LIFE);

function frecPick(store, char, now){
  const r = store[char] || { score: 0, at: now, count: 0, last: 0 };
  store[char] = { score: decayScore(r.score, now - r.at) + 1, at: now, count: r.count + 1, last: now };
  return store[char];
}
Enter fullscreen mode Exit fullscreen mode

Decay the old score to now, then add one. Four numbers per emoji: the decayed score, the timestamp it was decayed to, a plain count for display, and the last-used time as a tie-break. The incremental form is mathematically identical to summing a decayed weight over the entire event log — the property worth asserting in a test, and the reason you never have to keep the log. One detail only appears once tones exist: pick() records frecency against e.char, the untoned base, while inserting the toned string, because your medium-dark thumbs up and your default thumbs up are one habit.

The 2-D cursor and the caret

Model the selection as {r, c} over the cells rows only, and every arrow key is a couple of lines:

function moveCursor(lens, cur, dir){
  if (!lens.length) return { r: 0, c: 0 };
  let r = Math.min(Math.max(0, cur.r), lens.length - 1);
  let c = Math.min(Math.max(0, cur.c), lens[r] - 1);
  if (dir === "right"){ if (++c >= lens[r]){ r = (r + 1) % lens.length; c = 0; } }
  else if (dir === "left"){ if (--c < 0){ r = (r - 1 + lens.length) % lens.length; c = lens[r] - 1; } }
  else if (dir === "down"){ if (r < lens.length - 1){ r++; c = Math.min(c, lens[r] - 1); } }
  else if (dir === "up"){ if (r > 0){ r--; c = Math.min(c, lens[r] - 1); } }
  return { r, c };
}
Enter fullscreen mode Exit fullscreen mode

lens is cells-per-row, so the clamps handle a short final row for free. The important part is that the cursor is state, not focus: the virtual grid throws its DOM away on every scroll, and a selection living in document.activeElement would die with it. Instead exactly one rendered cell gets tabindex="0" and every other stays at -1 — the roving tabindex pattern that keeps the whole grid to a single tab stop — inside role="grid", role="row" and role="gridcell".

Then the detail users notice first, and the one the naive build always gets wrong:

range.deleteContents();
const node = document.createTextNode(text);
range.insertNode(node);
range.setStartAfter(node); range.collapse(true);
Enter fullscreen mode Exit fullscreen mode

Insert at the caret through the Selection and Range API, never by appending to the end of the contenteditable. The composer saves its live range on keyup, mouseup and blur, because opening the picker moves focus and the browser will have forgotten where you were. The demo seeds that caret eight characters into the message on boot, so the first pick visibly lands mid-sentence.

What the page actually shows you

There are no self-tests baked into this page and I am not going to quote a number I did not measure. What it does instead is make its own state legible while you use it: the read-out prints the normalised query, the match count, the four-tier histogram, the measured column count, rows-in-DOM against rows-total, the canvas height and the frecency top five with their decayed scores. The inspector prints the picked string's UTF-16 length, code point count, grapheme count, and a per-code-point table with the ZWJ, VS16 and Fitzpatrick annotations spelled out.

The engine is fenced off for exactly that reason:

// ===== ENGINE:BEGIN =====
// Pure logic — no DOM below this line until ENGINE:END. The node test file
// extracts exactly this block out of the HTML so the tests can never drift
// away from the code the page actually runs.
Enter fullscreen mode Exit fullscreen mode

normalize, tierOf, searchEmoji, inspect, applyTone, buildLayout, visibleRange, stickyRowAt, frecPick and moveCursor all live above ENGINE:END, and every one is pure. Nothing in that block touches the DOM, so nothing in it needs a browser to be checked — and because the tests slice the block out of the HTML itself, the tested code and the shipped code cannot drift apart.

The takeaway

An emoji picker looks like a decoration and is actually a compressed course in three things every product eventually needs: ranking a big list from typed text, drawing only what fits, and treating text as graphemes rather than characters. Get those right once and autocomplete, command palettes and mention menus are the same component in different clothes.

What made it small was putting every decision in a pure function of the data — the tier, the window, the sticky index, the frecency, the cursor — so the component stopped being a pile of event handlers and became something you can check.

Search it, long-press a hand for the five tones, drive it from the keyboard and read the true code points as they change: https://dev48v.infy.uk/design/day61-emoji-picker.html

Top comments (0)