GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

絵文字ピッカー 9パターン — 値で開閉

入力欄の下に残る空間の値がパネルの高さより小さいときだけパネルは上に開く位置に変わり、押し続けた時間の値が決めたしきい値を超えたときだけ同じ絵文字のバリエーションが扇状に広がり、カーソル位置の値をあらかじめ保存していた場合だけ選んだ絵文字がその場所に正確に挿入される。これが絵文字ピッカーの実際の動きです。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

以下の9つの絵文字ピッカーはどれもデータを取得したり保存したりしません。localStorageは使われておらず、最近使用の履歴は上書きされるだけのメモリ上の配列です。絵文字はすべてプレーンなUnicode文字で、画像スプライトも外部CDNも使いません。05番の長押しはイベントのタイムスタンプの差だけで時間を計り、本物のClipboard APIにもMath.randomにも触れていません。ライブラリもCDNスクリプトも使わず、9つとも単体のindex.htmlだけで動きます。

絵文字ピッカーの9パターンは押した時間や余白の値でどう動きを変えるか

9つのどれかを操作しても、同じ形が繰り返されます。何かの値が測られるか数えられ、絵文字かパネルの表示が変わり、短いCSSトランジションが見える部分を運びます。違うのは測られる対象です。01番は使用回数を数え、06番は押した矢印キーの値を格子の列数と組み合わせ、07番は入力欄の上下に残る空間の値を測ります。絵文字ピッカーとはこの「先に値を計算し、CSSトランジションはその2つの状態の間を補間するだけ」という仕組みの集まりで、9つとも入場アニメーションの使い回しではありません。

9つの絵文字ピッカーを面積・強度・動いたフレームで比べる測定値

下の表は各項目の측정.jsonを実測した値です — 累積面積はフレームごとに変化したピクセル比率を積み上げた数字、平均強度は変化したピクセルの明暗差の平均、動いたフレームは23フレーム中いくつで変化があったかを示します。コンポーネント単位のパネルは画面全体を覆う場面と違い、動きが起きるのは絵文字が選ばれる・パネルが開く・バリエーションが広がるといった入力の瞬間だけなので、面積が小さくても動いたフレーム数が多いことがあります。

# パターン 累積面積 平均強度 動いたフレーム
01 使用回数の値で並び替わる最近使用タブ 2.8% 93.4 10/23
02 スクロール位置とタブ番号が揃うカテゴリー切替 16.7% 64.8 9/23
03 入力文字と名前の類似度値で並ぶ検索 33.4% 137.6 16/23
04 選んだトーン値で絵文字一式が入れ替わるトーン切替 3.8% 58.4 11/23
05 しきい値を超えるとバリエーションが広がる長押し 5.3% 116.4 20/23
06 矢印キーの値で焦点が移動するキーボード操作 6.2% 63.1 12/23
07 残り空間の値を計算して開く方向が変わるアンカー配置 27.1% 63.8 12/23
08 保存したカーソル位置の値に挿入する挿入方式 2.8% 93.9 8/23
09 モーション低減値を検知して即表示されるパネル 10.6% 157.4 10/23

01使用回数の値で並び替わる最近使用タブ

絵文字を選ぶたびにcounts内のその絵文字の使用回数の値が一つ増え、rank()はその値が大きい順に並び替えます。renderRecent()は並べ替え前後の位置をgetBoundingClientRectで比べ、ズレた分だけ逆方向に動かしてから解放するFLIPという手法で要素を消さずに移動して見せます。

使用回数の値で並び替わる最近使用タブ
新しいタブで開く

01 · 使用回数の値で並び替わる最近使用タブ — 計測 累積面積 2.785% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 = '';
      }
    });

02スクロール位置とタブ番号が揃うカテゴリー切替

パネルを指で下げるとonScroll()が通過した距離の値(scrollTop)を読み取り、今表示中のカテゴリーのタブを自動で点灯させます。逆にタブを押すとgoTo()がそのタブの番号値の分だけパネルをスクロールし、syncingフラグで自分自身の呼び戻しを一時的に防ぎます。

スクロール位置とタブ番号が揃うカテゴリー切替
新しいタブで開く

02 · スクロール位置とタブ番号が揃うカテゴリー切替 — 計測 累積面積 16.651% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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)';
  }

  // 計算層: 指でパネルをスクロールした距離の値(scrollTop)を各セクションのオフセットと比べて
  // 今どのカテゴリーが見えているかを逆算し、その番号でタブ表示を合わせる。
  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 + ' カテゴリー表示中'; }
  }
  panelEl.addEventListener('scroll', function () { human(); onScroll(); });

03入力文字と名前の類似度値で並ぶ検索

score()が使う絵文字 検索方法は、入力文字と絵文字名を先頭から比べ、連続一致(streak)が続くほど値を大きく積み上げるというものです。rank()はその値でソートするため、完全一致でなくても近い並びの絵文字が上に来ます。

入力文字と名前の類似度値で並ぶ検索
新しいタブで開く

03 · 入力文字と名前の類似度値で並ぶ検索 — 計測 累積面積 33.422% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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; });
  }

04選んだトーン値で絵文字一式が入れ替わるトーン切替

setTone()で選んだトーン値を保存し、renderGrid()は描画のたびその値をcombine()に渡してベースの絵文字とFitzpatrickの肌色修飾子コードポイントを連結します。画像を差し替えるのではなく、Unicode文字列そのものを毎回組み立て直します。

選んだトーン値で対象の絵文字一式が入れ替わるトーン切替
新しいタブで開く

04 · 選んだトーン値で対象の絵文字一式が入れ替わるトーン切替 — 計測 累積面積 3.824% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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] + ' 選択、 ' + BASES.length + '件更新';
  }

05しきい値を超えるとバリエーションが広がる長押し

press()で指を置いた瞬間からtick()がrequestAnimationFrameで回り続け、経過時間の値をEMOJI_LONGPRESS_THRESHOLD_MSと比べます。値がしきい値を超えた瞬間だけopenFan()が呼ばれ、早く離せばselectDefault()が既定の一つを選びます。

押し続けた時間の値がしきい値を超えるとバリエーションが広がる長押し
新しいタブで開く

05 · 押し続けた時間の値がしきい値を超えるとバリエーションが広がる長押し — 計測 累積面積 5.267% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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.length + '個展開';
  }

06矢印キーの値で焦点が移動するキーボード操作

measureCols()は隣り合うマスのgetBoundingClientRect().topを比べて実際の列数を測ります。move()は押した矢印キーの値をその列数と組み合わせて次の焦点位置を計算するため、下矢印は固定の数ではなく実際の行の位置まで正確に移動します。

矢印キーの値で格子内の焦点が移動するキーボード操作
新しいタブで開く

06 · 矢印キーの値で格子内の焦点が移動するキーボード操作 — 計測 累積面積 6.242% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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);
  }

07残り空間の値を計算して開く方向が変わるアンカー配置

measureAndPlace()は入力欄の上下に残る空間の値をそれぞれ測り、下側の空間の値がパネルの高さよりEMOJI_PANEL_FLIP_MARGIN_PX分も余裕がないときだけ、パネルを入力欄の上に開く位置に変えます。

残り空間の値を計算して開く方向が変わるアンカー配置
新しいタブで開く

07 · 残り空間の値を計算して開く方向が変わるアンカー配置 — 計測 累積面積 27.115% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 = '下の余白 ' + Math.round(spaceBelow) + 'px · 上 ' + Math.round(spaceAbove) + 'px, ' + (openAbove ? '上に開いた' : '下に開いた');
    return openAbove;
  }

08保存したカーソル位置の値に挿入する挿入方式

入力欄がblurでフォーカスを失う直前、selectionStartの値がsavedStartに保存されます。絵文字を選ぶと、その値が指していた文字の間に挿入し、setSelectionRange()でカーソルをその直後に戻すため、常に末尾に押し出されることがありません。

保存したカーソル位置の値に正確に挿入する挿入方式
新しいタブで開く

08 · 保存したカーソル位置の値に正確に挿入する挿入方式 — 計測 累積面積 2.754% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 + '文字目の後ろに挿入、カーソル ' + landAt + '番目へ移動';
      btn.classList.add('ip__pick--pop');
      window.setTimeout(function () { btn.classList.remove('ip__pick--pop'); }, 260);
    });
  });

09モーション低減値を検知して即表示されるパネル

applyMotionPref()はmatchMedia('(prefers-reduced-motion: reduce)')の値とデモ用のforcedReduce値を合わせて読み、REDUCE_MOTION_DURATION_MSかNORMAL_MOTION_DURATION_MSかをその場でpanel.style.transitionDurationに直接書き込みます。値がオンのときは展開アニメーションの過程自体が飛ばされます。

モーション低減値を検知して展開なしで即表示されるパネル
新しいタブで開く

09 · モーション低減値を検知して展開なしで即表示されるパネル — 計測 累積面積 10.636% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 = '動作の軽減 ' + (reduce ? 'オン' : 'オフ');
    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 の値で ' + (pref.reduce ? '即座に表示' : '展開');
  }
  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);
  }

矢印キーのグリッド移動とreduced-motionが絵文字ピッカーのアクセシビリティに足すもの

06番のgrid.addEventListener('keydown', move)は一つのリスナーで矢印キー・Home・Endをまとめて受け止め、フォーカスはtabindexの付け替えと.focus({preventScroll:true})で移動します。マウスを使わない絵文字 入力 方法として、格子の端から端までキーボードだけで絵文字を選べるのはこの一つのリスナーのおかげです。移動のたびに絵文字名をlive要素へ書き込むaria-live領域が、スクリーンリーダーに現在位置を読み上げます。09番のapplyMotionPref()はシステムの動作軽減設定を検知するとREDUCE_MOTION_DURATION_MS(0ms)をそのままtransitionDurationに渡すため、展開アニメーションの過程が丸ごと飛ばされ、他の8パターンの自動デモループもprefers-reduced-motionが真なら開始しません。

検索欄・チャット入力・コメント欄で絵文字ピッカーが実際に使われる場所

チャットアプリのメッセージ入力欄では、カーソルが文中のどこにあっても08番のsavedStartの値がその位置を覚えているため、末尾ではなく本当にカーソルがあった場所へ絵文字が挿入されます。絵文字の色・表情違いを一つのボタンに畳んでおきたいSNSの投稿欄では、05番の長押しで初めてバリエーションが広がる設計が、普段は一つのアイコンだけを見せたい一覧の省スペースと両立します。

コメント欄やフォーラムの投稿フォームのように候補数が多い場面の絵文字 検索は、03番の類似度検索が完全一致でなくても近い名前の絵文字を上に出すため、正式名称を覚えていなくても目的の一つにたどり着けます。9パターン分のSCSSトークンとReact移植コード、そして上の3変形の差し替え版も同梱したzipファイルは8wfz3py4を入力すると開き、その値は画面上のすぐ近くに表示されています。

3つの値を変えると絵文字ピッカーの感触はどう変わるか

バリエーション 変更した値 感触の変化
検索デバウンス遅延値を調整する 03 EMOJI_SEARCH_DEBOUNCE_MS 200ms → 400ms 遅延値を短く縮めると一文字打つたびに結果がほぼ即座に再計算されリストが頻繁にちらつき、値を伸ばすとタイピングが止まってから一度だけ再計算されリストは落ち着いて変わる。
長押ししきい値の時間値を調整する 05 EMOJI_LONGPRESS_THRESHOLD_MS 550ms → 900ms しきい値を下げると軽く押しただけでバリエーションがすぐ開き誤操作が増え、値を上げるとかなり長く押さないと開かず短いタップでの選択とはっきり区別できる。
パネル反転の余白値を調整する 07 EMOJI_PANEL_FLIP_MARGIN_PX 8px → 24px 余白値を大きく取ると下の空間が十分に見えてもパネルは早めに上へ回って開き、値をゼロに近づけると本当に空間が足りないときだけぎりぎりで方向が変わる。

常時オープンの絵文字ピッカーが壊れる落とし穴 — 余白と長押しのしきい値

07番のEMOJI_PANEL_FLIP_MARGIN_PXを0に近づけすぎると、下の空間が本当にぎりぎりの高さの時だけ判定が反転し、僅かなスクロールでパネルが上下に暴れて見えることがあります。05番のEMOJI_LONGPRESS_THRESHOLD_MSを極端に短くすると、タップしたつもりの操作でもバリエーションが開いてしまい、既定の絵文字を一回で選べなくなります。どちらも値を変えるだけで壊れるため、上の変形の表にある2つの調整例のように、極端な値ではなく体感で確かめながら調整するのが安全です。

絵文字ピッカーがカラーピッカー・タグ入力・セグメントコントロールと違う理由

絵文字ピッカーはカラーピッカーやタグ入力・セグメントコントロールと近い見た目のパネルUIですが、扱う値の性質が違います。html カラーピッカー デザイン9選 — ホイールから履歴色までが扱うのは連続した色空間の値で、どの位置を選んでも常に一つの色に収束しますが、絵文字ピッカーの候補は離散したUnicode文字の集まりで、07番のように残り空間の値がパネル自体の開く位置を変える計算は色選択にはありません。タグ 入力 ui 9パターンが入力のたびに新しいチップ要素を増やし続けるのに対し、絵文字ピッカーは選んだあと要素を増やさず、08番のように既存の入力欄へ文字として挿入するだけです。セグメントコントロールUI 9選 — コピペOKは限られた少数の選択肢から一つを選ぶ形で、02番のカテゴリー切替のようなタブ連動はありますが、05番の長押しで候補が枝分かれする一段掘り下げた選択は持ちません。

絵文字ピッカーを組み込む前によくあるFAQ

絵文字ピッカーとはそもそも何を計算しているUIですか?

Unicode文字そのものは一切変わりません。各パターンは代わりに表示用の値(順位・スクロール位置・押し続けた時間・残り空間)を計算し、その値に応じて同じ固定の絵文字集合をどこに・どう描くかだけを変えています。

05番の長押しはタッチとマウスの両方で動きますか?

はい。press()とrelease()はpointerdown・pointerup・pointercancelというPointer Eventsに直接紐づいているため、指でもマウスでも同じEMOJI_LONGPRESS_THRESHOLD_MSの値で長押しが判定されます。自動デモも同じイベントを合成して発火させているだけです。

08番の挿入位置はウィンドウ幅を変えた直後にずれますか?

いいえ。savedStartが更新されるのはblurが起きた時だけで、ウィンドウ幅を変えてもそのイベントは発生せず、入力欄自身のselectionStartも動きません。次に本当にフォーカスが外れるまで保存された位置はそのまま正しく使われます。

prefers-reduced-motionメディアクエリの仕様はMDNのドキュメントに、絵文字自体のコードポイント一覧はUnicode Full Emoji Listにまとまっています。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。