GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Emoji Picker: 9 Patterns by One Value

Nine emoji picker patterns: The panel flips upward only when the space-below value is smaller than its height, variants fan out only past a held-time value,

Auto-plays · click a tile to jump to its section · all nine in one zip

None of the nine emoji pickers below fetch or persist anything. localStorage is never touched — the recents list is just an in-memory array that gets overwritten on reload. Every emoji is a plain Unicode character, not an image sprite or a CDN glyph. The long-press timer in item 05 runs on raw event-timestamp deltas. None of the nine ever calls the real Clipboard API or Math.random. No library, no CDN script — each of the nine runs from a single standalone index.html.

How nine emoji picker patterns turn a held time or a leftover space into motion

Operate any of the nine and the same shape repeats: some value gets measured or counted, an emoji or the panel changes what it shows, and a short CSS transition carries the visible part. What differs is which value gets measured. Item 01 counts how many times an emoji was picked, item 06 combines the arrow key pressed with the measured column count of the grid, and item 07 measures the leftover space above and below the input. This kind of emoji picker ui design computes the value first and lets the CSS transition merely interpolate between the two resulting states — none of the nine are just a recycled entrance animation.

Comparing the nine emoji picker patterns by area, intensity, and moved frames

The table below comes straight from each item's 측정.json. Cumulative area sums the share of pixels that changed per frame, average intensity is the mean brightness delta of the pixels that changed, and moved frames counts how many of 23 sampled frames had any change at all. Because these are component-sized panels rather than full-screen scenes, motion only happens at the moment of picking, opening, or fanning out, so a pattern can have a small area but still move across many frames.

# Pattern Cumulative area Avg intensity Moved frames
01 Recents tab reordered by a use-count value 2.8% 93.4 10/23
02 Category tabs synced to a scroll-position value 16.7% 64.8 9/23
03 Search ranked by a query-to-name similarity value 33.4% 137.6 16/23
04 Tone swap that regrows the grid from a chosen tone value 3.8% 58.4 11/23
05 Long-press that fans out once a threshold value is crossed 5.3% 116.4 20/23
06 Keyboard grid nav driven by a measured column value 6.2% 63.1 12/23
07 Anchored panel that flips based on a leftover-space value 27.1% 63.8 12/23
08 Insert-at-cursor driven by a saved cursor-position value 2.8% 93.9 8/23
09 Panel that renders instantly once a reduced-motion value is read 10.6% 157.4 10/23

01Recents tab reordered by a use-count value

Every pick increments that emoji's count inside counts, and rank() sorts by that value, largest first. renderRecent() reads each item's position with getBoundingClientRect before and after the reorder, then shifts anything that moved by the opposite delta and releases it — the FLIP technique, which relocates elements without ever destroying and recreating them.

Recent Tab That Reorders by a Usage-Count Value
Open in a new tab

01 · Recent Tab That Reorders by a Usage-Count Value — Measured area 2.785% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

RECENT_MAXSEQrenderRecent
    var before = {};
    Array.prototype.forEach.call(recentEl.children, function (li) { before[li.dataset.key] = li.getBoundingClientRect(); });
    var list = rank();
    recentEl.innerHTML = '';
    list.forEach(function (e, idx) {
      var li = document.createElement('li');
      li.className = 'ep__item';
      fillItem(li, e, idx);
      li.addEventListener('click', function () { human(); pick(e); });
      recentEl.appendChild(li);
    });
    Array.prototype.forEach.call(recentEl.children, function (li) {
      var b = before[li.dataset.key];
      if (!b) { li.classList.add('ep__item--enter'); requestAnimationFrame(function () { li.classList.remove('ep__item--enter'); }); return; }
      var a = li.getBoundingClientRect(), dx = b.left - a.left, dy = b.top - a.top;
      if (Math.abs(dx) > .5 || Math.abs(dy) > .5) {
        li.style.transition = 'none';
        li.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
        li.offsetHeight;
        li.style.transition = '';
        li.style.transform = '';
      }
    });

02Category tabs synced to a scroll-position value

Drag the panel and onScroll() reads the distance value it has scrolled (scrollTop) and compares it against each section's offset to work out which category is now in view, then syncs the tab. Press a tab instead and goTo() scrolls the panel by that tab's index value, with a syncing flag that stops the scroll handler from immediately calling itself back.

Category Switch Where Scroll Position and Tab Index Stay in Sync
Open in a new tab

02 · Category Switch Where Scroll Position and Tab Index Stay in Sync — Measured area 16.651% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

CATSsetActivescrollTop
  function setActive(i) {
    tabs.forEach(function (t, k) { t.setAttribute('aria-selected', k === i ? 'true' : 'false'); });
    var r = tabs[i].getBoundingClientRect(), pr = tabsEl.getBoundingClientRect();
    indEl.style.width = r.width + 'px';
    indEl.style.transform = 'translateX(' + (r.left - pr.left) + 'px)';
  }

  // calc layer: reads how far the panel has been dragged-scrolled (scrollTop) and compares it
  // against each section's offset to work out which category is now in view, then syncs the tab.
  function onScroll() {
    if (syncing) return;
    var dist = panelEl.scrollTop, i = 0;
    for (var k = 0; k < sections.length; k++) {
      if (sections[k].offsetTop - panelEl.clientTop <= dist + 18) i = k;
    }
    if (i !== active) { active = i; setActive(i); live.textContent = CATS[i].name + ' category now in view'; }
  }
  panelEl.addEventListener('scroll', function () { human(); onScroll(); });

03Search ranked by a query-to-name similarity value

score() is the emoji picker design behind the search box: it walks the query and the emoji's name together from the start, and every consecutive-match streak adds a larger and larger value to the running score. rank() sorts by that score, so names that are merely close, not identical, can still surface near the top.

Search That Ranks by a Similarity Score Between Typed Text and Name
Open in a new tab

03 · Search That Ranks by a Similarity Score Between Typed Text and Name — Measured area 33.422% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

EMOJI_SEARCH_DEBOUNCE_MSscorerank
  function score(query, name) {
    if (!query) return 1;
    var qi = 0, s = 0, streak = 0;
    for (var ni = 0; ni < name.length && qi < query.length; ni++) {
      if (name[ni] === query[qi]) {
        streak++;
        s += 1 + streak * 2 + (ni === 0 ? 4 : 0);
        qi++;
      } else {
        streak = 0;
      }
    }
    if (qi < query.length) return 0;
    return s;
  }

  function rank(query) {
    var q = query.trim().toLowerCase();
    return ITEMS.map(function (it) { return { it: it, s: score(q, it.name) }; })
      .filter(function (r) { return r.s > 0; })
      .sort(function (a, b) { return b.s - a.s; });
  }

04Tone swap that regrows the grid from a chosen tone value

setTone() stores the chosen tone value, and every redraw renderGrid() passes that value into combine(), which concatenates the base emoji codepoint with a Fitzpatrick skin-tone modifier codepoint. Nothing swaps an image — the Unicode string itself is rebuilt on every pass.

Tone Swap Where a Chosen Tone Value Redraws a Whole Emoji Set
Open in a new tab

04 · Tone Swap Where a Chosen Tone Value Redraws a Whole Emoji Set — Measured area 3.824% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

EMOJI_TONE_MODIFIERScombineBASES
  function renderGrid() {
    Array.prototype.forEach.call(grid.children, function (li) {
      var ch = li.querySelector('.tn__ch');
      if (mounted) { ch.classList.remove('tn__ch--swap'); void ch.offsetWidth; ch.classList.add('tn__ch--swap'); }
      ch.textContent = combine(li.dataset.base, tone);
    });
  }

  function setTone(i) {
    tone = i;
    swatchEls.forEach(function (b, j) { b.setAttribute('aria-checked', j === i ? 'true' : 'false'); });
    renderGrid();
    live.textContent = TONE_NAMES[i] + '  selected, ' + BASES.length + ' updated';
  }

05Long-press that fans out once a threshold value is crossed

The instant a finger lands, press() starts tick() looping on requestAnimationFrame, comparing the elapsed time value against EMOJI_LONGPRESS_THRESHOLD_MS on every frame. openFan() only fires the moment that value crosses the threshold; release early and selectDefault() picks the base emoji instead.

Long-Press That Fans Out Variants Once a Held-Time Value Crosses a Line
Open in a new tab

05 · Long-Press That Fans Out Variants Once a Held-Time Value Crosses a Line — Measured area 5.267% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

EMOJI_LONGPRESS_THRESHOLD_MStickopenFan
  function tick() {
    if (!pressing) return;
    var elapsed = performance.now() - pressStart;
    var pct = Math.min(1, elapsed / EMOJI_LONGPRESS_THRESHOLD_MS);
    ringFill.style.transform = 'scaleX(' + pct + ')';
    if (pct >= 1 && !fanOpened) openFan();
    rafId = requestAnimationFrame(tick);
  }

  function openFan() {
    fanOpened = true;
    fan.hidden = false;
    requestAnimationFrame(function () { fan.classList.add('lp__fan--open'); });
    live.textContent = 'Variants ' + VARIANTS.length + ' opened';
  }

06Keyboard grid nav driven by a measured column value

measureCols() compares the getBoundingClientRect().top of neighboring cells to work out how many columns actually fit per row. move() combines the arrow key pressed with that measured column value to compute the next focus index, so ArrowDown lands on the real row below instead of some fixed offset.

Keyboard Nav Where an Arrow-Key Value Moves Focus Across the Grid
Open in a new tab

06 · Keyboard Nav Where an Arrow-Key Value Moves Focus Across the Grid — Measured area 6.242% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

measureColsmovesetFocus
  function move(key) {
    var cols = measureCols();
    var next = focusIndex;
    if (key === 'ArrowRight') next = focusIndex + 1;
    else if (key === 'ArrowLeft') next = focusIndex - 1;
    else if (key === 'ArrowDown') next = focusIndex + cols;
    else if (key === 'ArrowUp') next = focusIndex - cols;
    else if (key === 'Home') next = 0;
    else if (key === 'End') next = cells.length - 1;
    if (next < 0 || next >= cells.length) return;
    setFocus(next, true);
  }

07Anchored panel that flips based on a leftover-space value

measureAndPlace() measures the leftover space value above and below the trigger, and only when the space-below value has less than EMOJI_PANEL_FLIP_MARGIN_PX of clearance against the panel height does the panel flip to open above the input instead of below it.

Anchor Placement That Flips Direction After Measuring Remaining Space
Open in a new tab

07 · Anchor Placement That Flips Direction After Measuring Remaining Space — Measured area 27.115% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

EMOJI_PANEL_FLIP_MARGIN_PXmeasureAndPlacemovePosition
  function measureAndPlace() {
    var vpRect = viewport.getBoundingClientRect();
    var trRect = trigger.getBoundingClientRect();
    var wasHidden = panel.hidden;
    panel.style.visibility = 'hidden';
    panel.hidden = false;
    var panelH = panel.offsetHeight;
    panel.hidden = wasHidden;
    panel.style.visibility = '';
    var spaceBelow = vpRect.bottom - trRect.bottom;
    var spaceAbove = trRect.top - vpRect.top;
    var openAbove = (spaceBelow - EMOJI_PANEL_FLIP_MARGIN_PX) < panelH && spaceAbove > spaceBelow;
    panel.classList.toggle('af__panel--above', openAbove);
    panel.classList.toggle('af__panel--below', !openAbove);
    if (openAbove) {
      panel.style.bottom = (vpRect.bottom - trRect.top + EMOJI_PANEL_FLIP_MARGIN_PX) + 'px';
      panel.style.top = 'auto';
    } else {
      panel.style.top = (trRect.bottom - vpRect.top + EMOJI_PANEL_FLIP_MARGIN_PX) + 'px';
      panel.style.bottom = 'auto';
    }
    panel.style.left = (trRect.left + trRect.width / 2 - vpRect.left) + 'px';
    live.textContent = 'Space below ' + Math.round(spaceBelow) + 'px · above ' + Math.round(spaceAbove) + 'px, ' + (openAbove ? 'opened upward' : 'opened downward');
    return openAbove;
  }

08Insert-at-cursor driven by a saved cursor-position value

The instant the input loses focus, blur saves its selectionStart value into savedStart. Pick an emoji afterward and it lands right at the character that value pointed to, with setSelectionRange() moving the cursor to just past it, so nothing ever just gets pushed onto the end.

Insert That Lands Exactly at a Saved Cursor-Position Value
Open in a new tab

08 · Insert That Lands Exactly at a Saved Cursor-Position Value — Measured area 2.754% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

savedStartselectionStartsetSelectionRange
  picks.forEach(function (btn) {
    btn.addEventListener('click', function () {
      human();
      var emoji = btn.textContent;
      var value = field.value;
      var before = value.slice(0, savedStart);
      var after = value.slice(savedEnd);
      field.value = before + emoji + after;
      var landAt = savedStart + emoji.length;
      field.focus({ preventScroll: true });
      field.setSelectionRange(landAt, landAt);
      savedStart = landAt;
      savedEnd = landAt;
      live.textContent = before.length + '-th character, cursor ' + landAt + '-th position';
      btn.classList.add('ip__pick--pop');
      window.setTimeout(function () { btn.classList.remove('ip__pick--pop'); }, 260);
    });
  });

09Panel that renders instantly once a reduced-motion value is read

applyMotionPref() reads matchMedia('(prefers-reduced-motion: reduce)') alongside a demo-only forcedReduce value, picks REDUCE_MOTION_DURATION_MS or NORMAL_MOTION_DURATION_MS, and writes that straight into panel.style.transitionDuration. When the value reads true, the entire expand transition is skipped, not just shortened.

Panel That Skips the Open Animation After Detecting a Reduced-Motion Value
Open in a new tab

09 · Panel That Skips the Open Animation After Detecting a Reduced-Motion Value — Measured area 10.636% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

REDUCE_MOTION_DURATION_MSapplyMotionPrefforcedReduce
  function applyMotionPref() {
    var reduce = reduceMQ.matches || forcedReduce;
    var ms = reduce ? REDUCE_MOTION_DURATION_MS : NORMAL_MOTION_DURATION_MS;
    panel.style.transitionDuration = ms + 'ms';
    badge.textContent = 'Reduced motion ' + (reduce ? 'on' : 'off');
    return { reduce: reduce, ms: ms };
  }
  applyMotionPref();

  function openPanel() {
    var pref = applyMotionPref();
    panel.hidden = false;
    requestAnimationFrame(function () { panel.classList.add('rp__panel--open'); });
    trigger.setAttribute('aria-expanded', 'true');
    live.textContent = pref.ms + 'ms value: ' + (pref.reduce ? 'shown instantly' : 'opened');
  }
  function closePanel() {
    panel.classList.remove('rp__panel--open');
    trigger.setAttribute('aria-expanded', 'false');
    var pref = applyMotionPref();
    window.setTimeout(function () { if (!panel.classList.contains('rp__panel--open')) panel.hidden = true; }, pref.ms + 20);
  }

What arrow-key grid nav and reduced-motion add to emoji picker accessibility

Item 06's single grid.addEventListener('keydown', move) catches the arrow keys, Home, and End together, and moves focus purely by reassigning tabindex and calling .focus({preventScroll:true}). That one listener is the entire reason a keyboard-only visitor can cross the grid edge to edge without ever touching a mouse, and an aria-live region writes the emoji's name on every move so a screen reader always announces the current cell. Item 09's applyMotionPref() reads the system's reduced-motion setting and, when true, feeds REDUCE_MOTION_DURATION_MS (0ms) straight into transitionDuration, so the expand transition is skipped outright — and none of the other eight patterns' looping auto-demo starts at all once prefers-reduced-motion reads true.

Where an emoji picker design actually earns its keep — search boxes, chat, comments

In a chat app's message field, no matter where the cursor sits mid-sentence, item 08's saved savedStart value remembers that exact spot, so a picked emoji lands where the cursor actually was instead of always at the end. On a social post composer that wants to keep one icon per emoji visible while still offering every skin tone or expression, item 05's long-press — where variants only fan out once the held time crosses a threshold — keeps the default view compact without hiding the deeper option.

In a comment box or forum post field where the candidate list runs long, item 03's similarity search surfaces close-but-not-exact name matches, so a visitor who doesn't remember the exact name still lands on the right emoji. The SCSS token set, all nine items ported to React, and builds of these three tuned variants ship together in a zip that opens with 8wfz3py4, typed in exactly as shown right next to it.

Three values that reshape how an emoji picker feels

Variant Value changed What shifts
Tune the search debounce delay 03 EMOJI_SEARCH_DEBOUNCE_MS 200ms → 400ms Shrink the delay value and results recompute on nearly every keystroke, flickering the list; stretch it and results only recompute once typing pauses, so the list settles.
Tune the long-press threshold time 05 EMOJI_LONGPRESS_THRESHOLD_MS 550ms → 900ms Lower the threshold and a light tap opens variants by accident; raise it and only a genuinely long hold opens them, keeping short taps cleanly separate from picks.
Tune the panel flip margin 07 EMOJI_PANEL_FLIP_MARGIN_PX 8px → 24px A larger margin value flips the panel upward earlier even when the space below still looks fine; near zero, the direction only changes when space is truly running out.

The trap that breaks an always-open emoji picker — margin and long-press threshold

Push item 07's EMOJI_PANEL_FLIP_MARGIN_PX too close to zero and the flip decision only kicks in when the space below is genuinely borderline, so a small scroll can flip the panel up and down repeatedly. Push item 05's EMOJI_LONGPRESS_THRESHOLD_MS too short and a tap that was meant to just pick the default emoji instead opens the variant fan, breaking single-tap selection outright. Both break from a single value change, so tune them by feel with small steps, the way the two variant rows above do, rather than jumping to an extreme.

Why an emoji picker isn't a color picker, a tag input, or a segmented control

An emoji picker shares a similar panel UI with a color picker, a tag input, or a segmented control, but the value each one actually manipulates is different. Color Picker HTML Design: 9 Ways — From Wheel to Recent Swatches works over a continuous color space, where any position you land on resolves to exactly one color, but an emoji picker's candidates are a discrete set of Unicode characters, and nothing in a color picker computes a leftover-space value that flips where the panel itself opens the way item 07 does. Tag Input Chip UI: 9 Patterns keeps adding a new chip element on every entry, while an emoji picker never grows new elements after a pick — item 08 just inserts a character into the input that was already there. Segmented Control UI: 9 Patterns, Copy-Paste Ready picks exactly one option from a short fixed list, with tab-sync moments like item 02's category switching, but it never branches one option into a fanned-out sub-choice the way item 05's long-press does.

FAQ before you wire up an emoji picker

What value is an emoji picker design actually computing?

The Unicode characters themselves never change. Each pattern instead computes a display-only value — rank, scroll position, held time, or leftover space — and uses that value to decide where and how to draw the same fixed set of emoji.

Does item 05's long-press work the same on touch and with a mouse?

Yes. press() and release() are wired straight to the Pointer Events pointerdown, pointerup, and pointercancel, so the same EMOJI_LONGPRESS_THRESHOLD_MS value governs a long-press whether it comes from a finger or a mouse. The auto-demo just dispatches the same events synthetically.

Does item 08's insert position drift right after a window resize?

No. savedStart only updates when blur fires, and resizing the window never fires that event or touches the input's own selectionStart. The saved position stays correct until focus genuinely leaves the field again.

The prefers-reduced-motion media query is documented on MDN, and the full emoji codepoint set is catalogued in the Unicode Full Emoji List.

Enter the archive password

The password is inside this article. You will find it as you read.